滚动加载实现方案与性能优化实战经验分享
优化前:卡得不行
上周上线了个新模块——商品瀑布流,后端给了个无限滚动接口,前端用 IntersectionObserver 监听底部元素,触发加载。听起来很标准对吧?结果用户一刷就卡顿,iOS 上手指一松直接跳帧,Android 更惨,滑到第 3 页就开始掉帧,Chrome DevTools Performance 面板里一看,主线程动不动就堵 120ms+,用户反馈“像在拖水泥桶”。最离谱的是,首页首屏渲染完才 1.2s,但滚动到第 50 条时,每次触发 loadMore,JS 执行时间飙到 480ms(光是 renderList 就占了 320ms),页面直接假死。
找到瘼颈了!
我先用 Chrome 的 Performance 录了一段 5 秒滚动操作,导出 trace 查看:不是网络慢(fetch 耗时平均 120ms),也不是 DOM 过大(总节点数才 1200+),而是每次 loadMore 后调用的 renderList(data) 太暴力——它把整个列表容器 innerHTML 清空再重拼,连带所有事件监听器全丢,还得重新 bind click、touchstart……更坑的是,我们用了 Vue 2,但没用 v-for + key,而是手动拼字符串,导致 diff 完全失效,每次都是全量重绘。
接着用 Memory 面板拍快照,发现滚动过程中内存持续上涨,GC 频繁,一查是滚动中反复 new Date()、JSON.parse(JSON.stringify(item)) 做深拷贝(为了防响应式污染,结果自己造了垃圾)。还有个隐藏雷:我们监听了 window.onscroll,没节流也没 passive,iOS 上 touchmove 被强制同步执行,直接阻塞滚动线程。
试了几种方案,最后这个效果最好
我折腾了三天,试过三套方案:
- 方案 A:换 Vue 3 +
v-infinite-scroll插件 → 不行,插件内部仍用 scroll 事件 + setTimeout 节流,iOS 下还是卡 - 方案 B:全量切 IntersectionObserver + requestIdleCallback 拆分渲染 → 表面不卡了,但数据一多,idle 时间不够,loadMore 延迟严重,用户划到底部等 2 秒才出新内容
- 方案 C(最终落地):IntersectionObserver + 增量 DOM 插入 + 事件委托 + 滚动监听降级兜底
核心就两点:不删 DOM,只 append;不绑事件,用 delegation。
核心代码就这几行
先看关键的滚动监听部分(兼容性兜底做了,但主逻辑走 Observer):
// 初始化时注册一次
const observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !loading && hasMore) {
loading = true;
loadMore().finally(() => loading = false);
}
});
},
{ threshold: 0.1, rootMargin: '200px' } // 提前 200px 触发,避免白屏
);
// 只监听最后一个 item(不是监听 container!)
if (lastItemRef.value) {
observer.observe(lastItemRef.value);
}
重点在 loadMore 的渲染逻辑——这里我砍掉了所有 innerHTML = ” + str,改成原生 appendChild:
function appendItems(items) {
const fragment = document.createDocumentFragment();
items.forEach(item => {
const el = document.createElement('div');
el.className = 'item';
el.dataset.id = item.id;
el.innerHTML =
<img src="${item.img}" alt="${item.title}" loading="lazy">
<h3>${item.title}</h3>
<p>¥${item.price}</p>
;
fragment.appendChild(el);
});
listContainer.appendChild(fragment); // 一次插入,非循环 append
}
事件绑定也改了:不再给每个 item 绑 click,而是在 listContainer 上委托:
listContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('item')) {
const id = e.target.dataset.id;
trackClick(id);
openDetail(id);
}
});
另外,干掉了所有 JSON.parse(JSON.stringify()),改用 Object.assign({}, item),或直接读原始 data(后端已保证不可变)。
踩坑提醒:这三点一定注意
- IntersectionObserver 的 rootMargin 别写死 px:我们一开始写了 ‘100px’,结果在 iPad 上触发太晚,用户看到空白才加载。后来改成 ’20vh’,配合 CSS 的
height: 100vh,适配更好 - 滚动监听必须加 passive: true:哪怕只做兜底,也要加。否则 iOS Safari 会强制同步执行,
window.addEventListener('scroll', handler, { passive: true }),不加就是性能杀手 - 图片 loading=”lazy” 是底线,但别信它能扛住瀑布流:我们测试发现,Chrome 110+ 下 lazy 加载在快速滚动时会漏掉很多图片,所以额外加了
img.loading { opacity: 0; transition: opacity .2s } img.loaded { opacity: 1 },并在 appendItems 里主动触发img.decode()防止解码阻塞
优化后:流畅多了
改完上线灰度 10% 用户,跑了一整天,真实数据如下:
- 主线程平均阻塞时间:从 480ms → 62ms(降幅 87%)
- 滚动帧率(FPS):iOS 从平均 32fps → 稳定 58~60fps
- loadMore 触发到新内容可见时间:从 1.8s → 820ms(实测第 100 条)
- 内存增长曲线:GC 频次下降 90%,峰值内存从 180MB → 95MB
用户反馈也变了:“这次刷得挺顺”“终于不用等半天了”。虽然还没达到「丝般顺滑」,但至少不卡了——对我们这种赶工期的项目,够用。
性能数据对比
这是同一台 iPhone 13(iOS 17.5)上,滚动到第 80 条时的 Performance 截图数据(本地复现):
- 优化前:单次 loadMore 调用耗时 460~510ms,其中 JS 执行 320ms,Layout 90ms,Paint 50ms
- 优化后:单次 loadMore 耗时 70~90ms,JS 执行压到 28ms(主要花在 fetch 和 createElement),Layout 降到 12ms,Paint 8ms
最关键的是:优化后,滚动过程中主线程几乎不出现红色长条(long task),最长任务从 420ms 降到 32ms。
以上是我踩坑后的总结,希望对你有帮助
这个方案不是银弹。比如当用户疯狂上拉下拉,Observer 的回调堆积,我们没做防抖(怕影响体验),所以极端情况下还是会小卡一下。但权衡之后,选择「默认流畅 + 极端容忍小卡」,比「永远不卡但延迟高」更符合业务实际。
如果你有更好的滚动加载实践——比如用 ResizeObserver 替代 IntersectionObserver、或者 Web Worker 处理数据转换、甚至 Service Worker 缓存分页数据——欢迎评论区交流。我最近也在看 React 的 useInfiniteScroll 库源码,打算下篇聊聊它的 diff 优化思路。
