虚拟列表实现原理与性能优化实战
又踩坑了,滚动到一半卡住不动
项目里用虚拟列表展示几千条数据,本来挺顺的,结果测试一拉到底就发现不对劲:滚到中间某处突然卡住,怎么划都不动。最离谱的是,手指还在滑,但列表就是不跟着走,像被冻住了。
第一反应是 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 优化过?我还没敢在生产环境试,怕兼容性炸了。

暂无评论