虚拟列表实现原理与性能优化实战

梦玲🍀 交互 阅读 2,151
赞 42 收藏
二维码
手机扫码查看
反馈

又踩坑了,滚动到一半卡住不动

项目里用虚拟列表展示几千条数据,本来挺顺的,结果测试一拉到底就发现不对劲:滚到中间某处突然卡住,怎么划都不动。最离谱的是,手指还在滑,但列表就是不跟着走,像被冻住了。

虚拟列表实现原理与性能优化实战

第一反应是 touchmove 被阻止了?查了一圈 event.preventDefault() 都没乱加,而且只在特定机型上复现(没错,又是安卓机的问题)。后来打印了 scroll event,发现事件根本没触发——不是逻辑问题,是滚动行为压根没传进来。

折腾了半天发现:容器高度算错了

这里我踩了个坑:我以为只要给外层容器一个固定 height 就行,比如 600px,然后内部渲染可视区域元素 + 占位 div。但实际上,如果父级没有明确的高度限制,某些浏览器(尤其是微信内置浏览器)会把整个页面当可滚动区域,导致你绑定的 scroll 容器收不到事件。

一开始我这样写的:

<div class="virtual-list-container">
  <div class="scroll-content" ref="content"></div>
</div>
.virtual-list-container {
  overflow-y: auto;
  /* 没设 height */
}

看着没问题,但加上 height: 100% 或具体像素值后才正常。后来改成:

.virtual-list-container {
  height: 600px;
  overflow-y: auto;
  position: relative;
}

这一改,滚动立马通了。但新的问题是:内容区撑不开,上下空白太大,首屏渲染错位。

三种方案对比,我选了最简单的

试过动态计算 item 高度并累计生成 offset map,也就是每个 item 的 top 值都存下来,用来快速定位。这方法精度高,适合高度不一致的场景,但我这个列表每一项都是固定高度(80px),完全没必要搞那么复杂。

第二种是用 IntersectionObserver 监听首尾元素是否进入视口,动态更新渲染范围。听起来很现代,也省性能,但兼容性在低端安卓机上不太稳,加上初始化延迟明显,滑动时有“闪现”感,果断放弃。

最后回到基础做法:根据 scrollTop / itemHeight 算出当前应该显示哪些 item,再前后多渲染几条做缓冲(buffer size = 5),保证滑动流畅。虽然老派,但亲测有效。

核心代码就这几行

关键其实在两个地方:一个是滚动监听和渲染更新的节流,另一个是占位 div 的正确使用。

const ITEM_HEIGHT = 80; // 每项固定高度
const BUFFER_SIZE = 5; // 上下各多渲染5条

export default {
  data() {
    return {
      visibleList: [],
      start: 0,
      end: 0,
      total: 3000, // 总数
      containerHeight: 600,
    };
  },
  mounted() {
    this.updateVisibleItems();
    this.$refs.container.addEventListener('scroll', this.handleScroll);
  },
  methods: {
    handleScroll: _.throttle(function () {
      const scrollTop = this.$refs.container.scrollTop;
      const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER_SIZE);
      const end = Math.min(
        this.total,
        start + Math.ceil(this.containerHeight / ITEM_HEIGHT) + BUFFER_SIZE * 2
      );

      if (start !== this.start || end !== this.end) {
        this.start = start;
        this.end = end;
        this.updateVisibleItems();
      }
    }, 16),

    updateVisibleItems() {
      const list = [];
      for (let i = this.start; i < this.end; i++) {
        list.push({
          index: i,
          style: { top: ${i * ITEM_HEIGHT}px, height: ${ITEM_HEIGHT}px },
        });
      }
      this.visibleList = list;
    },
  },
};
<div
  class="virtual-list-container"
  ref="container"
  style="height: 600px; overflow-y: auto; position: relative;"
>
  <!-- 真实内容容器 -->
  <div
    class="scroll-content"
    :style="{ height: total * ITEM_HEIGHT + 'px', position: 'relative' }"
  >
    <div
      v-for="item in visibleList"
      :key="item.index"
      class="virtual-item"
      :style="item.style"
    >
      Item {{ item.index }}
    </div>
  </div>
</div>

这里的重点是:

  • 外层容器必须有明确 height 和 overflow-y: auto,不然 scroll 不生效
  • 内部 content 设置总高度(total * ITEM_HEIGHT)作为滚动空间,但只渲染可见部分
  • 每一项通过 top 定位,脱离文档流,避免 reflow
  • handleScroll 加了 throttle(16ms),防止频繁触发影响性能

踩坑提醒:这三点一定注意

1. 别用 window.scroll,一定要绑在具体容器上。否则移动端容易冲突,特别是嵌套页面或者有 fixed 导航栏的时候。

2. scrollTop 在 iOS 上偶尔不准,尤其是在快速滑动回弹时。我的解决办法是加个容差判断:

const THRESHOLD = 20;
if (Math.abs(start - this.start) > THRESHOLD || Math.abs(end - this.end) > THRESHOLD) {
  // 强制刷新
}

3. DOM 元素复用?想多了。之前试图用 pool 缓存 DOM 节点,结果内存反而更高,因为 Vue 组件实例没释放干净。现在直接 let it go,靠 key + v-for diff 就够用了。

还有个小毛病,但不影响上线

改完之后,滑动基本丝滑,唯一问题是快速滚动停下瞬间,有时候会看到一条空白缝,大概持续一帧。查了是渲染延迟,可能是因为 throttle 丢了几帧,或者 VNode 更新跟不上。

后来试了下把 BUFFER_SIZE 从 5 改成 8,勉强压下去了,但也增加了初始渲染负担。权衡之下觉得可以接受,毕竟用户不会一直猛划。

另外试过用 requestAnimationFrame 替代 throttle,发现不如预期稳定,特别是在低端机上反而更卡。所以最终还是保留 lodash.throttle(16),至少节奏可控。

以上是我踩坑后的总结

虚拟列表听着高级,其实核心就两点:控制渲染数量、维持滚动体验。实现方式很多,但我建议先从最简单做起,别一上来就搞动态高度测量、懒加载、回收池那一套,容易把自己绕进去。

特别是移动端,更要小心各种浏览器差异。这次问题根源其实是 CSS 布局没写全,导致 scroll 容器失效,这种低级错误居然也能上线前漏掉,只能说测试覆盖不够。

如果你有更好的方案欢迎评论区交流。比如有没有人用 CSS contain-intrinsic-size 配合 virtual list 优化过?我还没敢在生产环境试,怕兼容性炸了。

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

暂无评论