滚动性能优化实战:提升页面流畅度的关键技术

W″思源 优化 阅读 1,007
赞 23 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

最近做了一个长列表页,用户一滑到底,页面卡得像PPT。我一开始以为是数据量太大,后来发现其实是滚动事件没处理好。折腾了两天,终于把帧率从15拉到60,亲测有效。今天就把实战经验分享出来,核心就一句话:别让滚动事件直接触发重排重绘

滚动性能优化实战:提升页面流畅度的关键技术

最简单的优化方式,就是用 requestAnimationFrame 包裹你的滚动逻辑。比如你想监听滚动位置做懒加载:

let ticking = false;

function updateScrollPosition() {
  const scrollTop = window.scrollY;
  // 你的业务逻辑,比如判断是否要加载新数据
  console.log('当前滚动位置:', scrollTop);
  ticking = false;
}

window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(updateScrollPosition);
    ticking = true;
  }
});

这个模式我用了快五年了,稳定可靠。关键点在于 ticking 标志位,避免在同一个帧内重复执行。很多人直接写 requestAnimationFrame(() => { ... }),结果还是每帧都跑,等于没优化。

又踩坑了,touchmove滚动失效

在移动端,光靠 scroll 事件不够,尤其是那种局部滚动区域(比如弹窗里的列表)。这时候就得上 touchmove。但这里有个大坑:默认的 touchmove 会触发浏览器的默认滚动行为,而且很难控制

我之前在一个项目里,想在弹窗里实现下拉刷新,结果手指一动整个页面都在滚。后来发现必须加 passive: false,否则 preventDefault() 会失效(Chrome 56+ 默认 passive 为 true):

const scrollContainer = document.querySelector('.scroll-list');

scrollContainer.addEventListener('touchmove', (e) => {
  // 阻止默认行为,只让容器内部滚动
  e.preventDefault();
  // 你的自定义滚动逻辑
}, { passive: false });

但注意!passive: false 会带来性能损失,因为浏览器不能提前知道你是否会阻止默认行为。所以只在确实需要阻止默认行为时才用,比如自定义滚动条、下拉刷新这种场景。普通列表滚动,建议直接用 CSS 的 overflow: auto,别自己造轮子。

三种主流滚动处理方案

根据项目复杂度,我一般分三种情况处理:

  • 简单页面:直接用 scroll + requestAnimationFrame,够用又省事
  • 复杂交互(如虚拟列表):用 Intersection Observer API 监听元素进出视口,完全避开滚动事件
  • 高性能需求(如游戏、动画):用 transform + will-change 做硬件加速,滚动时只改 transform,不触发布局

比如虚拟列表,我常用 Intersection Observer:

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      // 加载数据或渲染内容
      loadMoreData();
    }
  });
}, {
  rootMargin: '100px' // 提前100px触发
});

observer.observe(document.querySelector('#trigger-element'));

这种方式比监听 scroll 轻量太多,浏览器原生支持,性能开销极小。我在一个商品列表页用这个方案,滚动流畅度直接起飞。

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

第一,别在滚动回调里写复杂计算。我见过有人在 scroll 里跑 for 循环遍历几百个 DOM 元素,帧率直接掉到个位数。如果必须做复杂操作,要么节流,要么移到 Web Worker 里(虽然麻烦,但值得)。

第二,CSS 动画尽量用 transform 和 opacity。比如滚动时 header 渐隐,别改 topheight,用 transform: translateY()。这样能走 GPU 加速,不触发重排。实测差距非常明显。

.header {
  will-change: transform;
  transition: transform 0.2s ease;
}

.header.hidden {
  transform: translateY(-100%);
}

第三,移动端慎用 fixed 定位。iOS 上 fixed 元素在滚动时会频繁重绘,特别卡。我的解决方案是:用 absolute + JS 动态更新 top 值,或者干脆用 sticky(现代浏览器支持不错)。

谁更灵活?谁更省事?

如果你只是做个普通页面,别折腾太多。用原生 scroll 事件 + rAF 就够了。但如果你在做类似 feed 流、聊天窗口这种高频滚动场景,强烈建议上虚拟列表。我用过 react-window 和 vue-virtual-scroll-list,效果拔群,DOM 节点从上千个降到几十个,内存占用直降 80%。

不过虚拟列表也有缺点:首屏渲染可能略慢(因为要计算高度),而且滚动条长度不准。但这些小问题比起卡顿,根本不算事。我现在的项目基本都默认上虚拟列表,除非列表少于 20 条。

核心代码就这几行

最后贴个完整的小例子,包含防抖、rAF、和基础边界检测:

class ScrollHandler {
  constructor() {
    this.ticking = false;
    this.lastScrollTop = 0;
    this.init();
  }

  init() {
    window.addEventListener('scroll', () => {
      if (!this.ticking) {
        requestAnimationFrame(() => this.update());
        this.ticking = true;
      }
    });
  }

  update() {
    const scrollTop = window.scrollY;
    const isScrollingDown = scrollTop > this.lastScrollTop;

    // 示例:向下滚动时隐藏 header
    const header = document.querySelector('.header');
    if (header) {
      header.classList.toggle('hidden', isScrollingDown && scrollTop > 100);
    }

    this.lastScrollTop = scrollTop;
    this.ticking = false;
  }
}

// 启动
new ScrollHandler();

这段代码我复制粘贴到新项目里至少十次了,改改就能用。关键是结构清晰,扩展方便。比如你想加 lazy load,就在 update 里加判断逻辑就行。

结尾碎碎念

滚动优化说难不难,说易也不易。核心思路就两个:减少工作量(节流/防抖/虚拟列表)、减少重排重绘(transform/opacity)。我踩过的坑基本都列在这了,希望你能少走弯路。

以上是我个人对滚动优化的完整讲解,有更优的实现方式欢迎评论区交流。这个技巧的拓展用法还有很多(比如结合 ResizeObserver 处理窗口缩放),后续会继续分享这类博客。毕竟前端性能优化,永远是个进行时。

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

暂无评论