List列表性能优化的实战技巧与避坑指南

宇文焦铭 组件 阅读 1,965
赞 9 收藏
二维码
手机扫码查看
反馈

又踩坑了,下拉加载卡成PPT

今天上线前最后测一遍列表页,手一滑往下一拽——好家伙,整个页面直接卡住两秒,手指都僵了。这哪是下拉刷新,这是给我上刑呢。

List列表性能优化的实战技巧与避坑指南

项目是个移动端的资讯流,用 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?我倒是想试,但现在得赶下一个需求了……

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论