滚动加载实现方案与性能优化实战经验分享

仪凡 交互 阅读 881
赞 40 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周上线了个新模块——商品瀑布流,后端给了个无限滚动接口,前端用 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 优化思路。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
UX卫利
UX卫利 Lv1
读完这篇文章,我学会了如何更好地理解产品经理的需求,提升了开发效率。
点赞
2026-02-19 19:25
Mc.树灿
Mc.树灿 Lv1
文章里的内容很有启发性,让我对很多知识点有了新的思考和理解。
点赞 3
2026-02-14 11:25