List列表性能优化的实战技巧与避坑指南
又踩坑了,下拉加载卡成PPT
今天上线前最后测一遍列表页,手一滑往下一拽——好家伙,整个页面直接卡住两秒,手指都僵了。这哪是下拉刷新,这是给我上刑呢。
项目是个移动端的资讯流,用 Vue 3 + Vant 搭的架子,数据靠分页接口从 https://jztheme.com/api/news 拿。之前一直用 Vant 的 List 组件自带的 load 事件做懒加载,写着省事,也没出过啥大问题。结果这次 QA 扔了个测试机过来(安卓老机型),一滑就卡,FPS 掉到个位数。
第一反应是“数据太多了吧?”,赶紧 console.log 了一下每页返回的条数——才 10 条,DOM 结构也不复杂,顶多就是个标题+摘要+图片,不应该啊。
排查过程:从怀疑人生到怀疑组件
先看性能面板,一滚动就一堆 reflow 和 layout trashing,八成是频繁操作 DOM 引起的。但我这列表也没啥动态样式啊,连 class 切换都是基于 state 的。
后来盯着 Vant 的 List 文档看了一遍,突然意识到一个问题:List 组件每次触发 load,我都在往数组里 push 新数据,但没做节流。如果用户手抖多拉了几次,就会连续请求、连续渲染,而 Vue 的响应式系统会立刻追踪新数据,导致批量更新 DOM。
试了几个方案:
- 防抖请求:不行,load 是滚动到底部自动触发的,防抖会直接让加载时机错乱
- 限制同时请求数:加了个 loading 标志位,但卡顿依旧,说明问题不在请求本身
- 用虚拟滚动:一听就很重,临时改不现实,而且就几十条数据都要上虚拟滚动,那这框架也太脆了
折腾了半天发现,真正的罪魁祸首其实是——每一次 push 都在触发视图重绘,而手机浏览器对长列表的增量渲染优化很差。
核心代码就这几行
最后的解法其实很简单:别让数据一条条地进,攒一波再一起塞进去。
我在组件里加了个临时缓存数组,请求回来的数据先放这里,等攒够两页或者用户停止滚动 300ms 后,再统一合并到主数据源。这样可以减少一半以上的 render 触发次数。
export default {
data() {
return {
list: [],
tempList: [], // 临时缓存
loading: false,
finished: false,
batchSize: 20, // 每批处理数量
timer: null
}
},
methods: {
async onLoad() {
if (this.loading || this.finished) return
this.loading = true
try {
const response = await fetch(https://jztheme.com/api/news?page=1&size=10)
const data = await response.json()
if (data.length === 0) {
this.finished = true
return
}
// 先存到临时数组
this.tempList.push(...data)
// 清除旧的延迟合并任务
if (this.timer) clearTimeout(this.timer)
// 延迟合并,避免频繁更新
this.timer = setTimeout(() => {
this.list.push(...this.tempList)
this.tempList = []
this.$refs.listElement.check() // 通知 Vant List 更新状态
}, 300)
} catch (err) {
console.error(err)
} finally {
this.loading = false
}
}
}
}
对应的模板也很简单:
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
ref="listElement"
>
<div v-for="item in list" :key="item.id" class="news-item">
<h3>{{ item.title }}</h3>
<p>{{ item.summary }}</p>
<img v-if="item.image" :src="item.image" alt="" />
</div>
</van-list>
这里注意我踩过一次坑:this.$refs.listElement.check() 必须手动调,因为 Vant List 是通过监听 list 数据变化来判断是否需要继续 load 的,你要是把数据扔 temp 数组里不合并,它就不知道自己该不该再触发 load。
还有个小问题没解决
这个方案改完后,滑动流畅多了,帧率稳定在 50fps 以上,老机型也能接受了。但有个小瑕疵:用户快速滑动时,可能会出现“空白段落”,因为你看到的是旧的 list 数据,temp 里的还没合并进来。
我也想过用 Intersection Observer 监听可视区域,只渲染当前屏幕附近的 item,也就是所谓的“虚拟列表”。但那样就得自己实现一套滚动容器,Vant 的 List 直接废掉,成本太高。现在这个延迟合并最多也就 300ms,用户几乎感知不到,就先这么凑合着吧。
另一个优化点是,我把 batchSize 设成了可配置项,不同设备可以动态调整。比如检测到是低端安卓机,就改成每页直接拿 20 条,减少请求次数。
踩坑提醒:这三点一定注意
- 不要无脑 push 数据:尤其在移动端,每次数组变更都会触发 diff 和 patch,量一大就卡
- temp 缓存记得清空:之前有一次忘了
this.tempList = [],结果数据重复叠加,差点线上炸库 - check() 得手动调:Vant List 不会自动感知外部状态变化,你得明确告诉它“哥们儿,该干活了”
为什么不用虚拟滚动?
不是不想用,是真不敢用。我们项目里已经有七八个列表页了,全用 Vant List 搞的,现在为了性能一个个改造成虚拟滚动,工作量太大。而且虚拟滚动对 CSS 布局要求高,稍微一改结构就得重新算高度,维护成本翻倍。
像 react-window 或 vue-virtual-scroller 这种库确实强,但你要考虑团队协作、兼容性、调试难度。我们现在这方案虽然土,但胜在改动小、风险低、见效快。
说白了,很多时候前端优化不是追求理论最优,而是找个能上线、不出事、别返工的解法。
结语
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的方案欢迎评论区交流。比如有没有人试过 MutationObserver 来监听数组变化然后自动 flush?我倒是想试,但现在得赶下一个需求了……

暂无评论