Smooth Scroll 实现原理与前端性能优化实战经验

Mr.倚轩 交互 阅读 1,427
赞 36 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上个月接手一个老项目,首页有个“锚点导航”功能,点击侧边栏的链接会平滑滚动到对应区块。听起来很基础对吧?但实际体验——简直灾难。我用 iPhone 12 测试,手指一碰链接,页面卡顿半秒才开始动,滚动过程中掉帧严重,甚至偶尔直接卡死。安卓机更惨,有些低端机直接白屏。

Smooth Scroll 实现原理与前端性能优化实战经验

用户反馈也炸了:“点一下要等半天”“滚动像幻灯片”。我一开始以为是 CSS 动画的问题,结果查了一圈发现,根本不是动画本身慢,而是整个主线程被占满了。

找到瓶颈了!

折腾了半天,我打开 Chrome DevTools 的 Performance 面板录了一次点击滚动操作。结果一目了然:每次点击,主线程都被一堆 JavaScript 任务塞满,其中最耗时的是一个叫 scrollHandler 的函数,它在滚动过程中疯狂触发,每帧都执行几十次 DOM 查询和计算。

再看代码,果然踩了经典坑:用了原生的 scroll 事件监听,而且没做任何节流或防抖。更糟的是,滚动回调里还调用了 getBoundingClientRect()querySelectorAll(),这些操作都会强制浏览器重排(reflow),性能杀手实锤。

另外,项目里用的还是老式的 window.scrollTo({ behavior: 'smooth' }),虽然简单,但在低端设备上表现极差,尤其是当目标元素位置动态变化时,浏览器会反复计算路径,导致主线程长时间阻塞。

核心优化方案:自己写滚动逻辑 + requestAnimationFrame

试了几种方案后,最后决定放弃原生 smooth scroll,自己用 requestAnimationFrame 实现一个轻量级的滚动器。核心思路就两点:一是用 rAF 控制帧率,避免过度渲染;二是预计算滚动路径,避免滚动中反复查询 DOM。

先上优化前的代码(简化版):

// 优化前:直接用原生 smooth scroll
document.querySelectorAll('a[href^="#"]').forEach(link => {
  link.addEventListener('click', (e) => {
    e.preventDefault();
    const target = document.querySelector(link.getAttribute('href'));
    if (target) {
      window.scrollTo({
        top: target.offsetTop,
        behavior: 'smooth'
      });
    }
  });
});

这段代码在桌面端还行,但在移动端,尤其是内容多、DOM 复杂的页面,性能崩得厉害。

优化后的方案,我封装了一个 smoothScrollTo 函数:

function smoothScrollTo(targetTop, duration = 500) {
  const start = window.pageYOffset;
  const startTime = performance.now();
  const distance = targetTop - start;

  function animate(currentTime) {
    const timeElapsed = currentTime - startTime;
    const progress = Math.min(timeElapsed / duration, 1);
    // 使用 ease-out 缓动函数
    const easeOutCubic = 1 - Math.pow(1 - progress, 3);
    const scrollTop = start + distance * easeOutCubic;

    window.scrollTo(0, scrollTop);

    if (progress < 1) {
      requestAnimationFrame(animate);
    }
  }

  requestAnimationFrame(animate);
}

然后在点击事件里调用它:

document.querySelectorAll('a[href^="#"]').forEach(link => {
  link.addEventListener('click', (e) => {
    e.preventDefault();
    const targetId = link.getAttribute('href');
    const target = document.querySelector(targetId);
    if (target) {
      // 关键:提前计算 offsetTop,避免滚动中查询
      const targetTop = target.offsetTop;
      smoothScrollTo(targetTop, 600);
    }
  });
});

这里注意我踩过好几次坑:一定要在点击时就计算好 targetTop,而不是在动画每一帧里去查。因为如果目标元素高度在滚动过程中变化(比如图片懒加载后撑开),会导致跳动,但至少不会卡死。

额外优化:滚动监听也得节流

除了滚动行为本身,页面里还有个吸顶导航,需要监听滚动位置。原来的代码是这样的:

window.addEventListener('scroll', () => {
  const nav = document.querySelector('.nav');
  nav.classList.toggle('fixed', window.scrollY > 100);
});

这玩意儿在快速滚动时每秒触发上百次,直接拖垮性能。我加了个简单的节流:

let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      const nav = document.querySelector('.nav');
      nav.classList.toggle('fixed', window.scrollY > 100);
      ticking = false;
    });
    ticking = true;
  }
});

或者用更现代的 IntersectionObserver 来替代,但考虑到兼容性,这次先用 rAF 节流搞定。

性能数据对比

优化前后,我在同一台 iPhone 12 上做了测试(使用 Safari Web Inspector 的 Timelines):

  • 优化前:点击锚点后,主线程阻塞约 420ms,滚动过程 FPS 掉到 18-22,总滚动完成时间约 1.2s
  • 优化后:主线程阻塞降至 45ms,滚动过程稳定在 55-60 FPS,总滚动完成时间 620ms

在低端安卓机(Redmi Note 9)上差距更明显:优化前经常卡死无响应,优化后基本流畅,虽然偶尔掉到 40 FPS,但至少能用。

最关键的是,用户反馈“卡顿”“白屏”的问题基本消失了。虽然不是完美 60 FPS,但日常使用完全够用。

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

第一,别在滚动动画里做 DOM 查询。哪怕只查一次 offsetTop,如果元素很多,也会触发重排。提前算好,存变量。

第二,缓动函数别用太复杂的数学公式。我一开始试了贝塞尔曲线,结果计算开销大,反而不如简单的 easeOutCubic。性能优先,效果其次。

第三,如果页面有动态内容(比如异步加载的模块),滚动目标位置可能变化。这时候要么在内容加载完后重新绑定事件,要么加个容错机制(比如滚动结束后校验位置,偏差大就微调)。我这次偷懒没处理,目前影响不大,但心里知道是个隐患。

结尾

以上是我对 Smooth Scroll 性能优化的实战总结。自己写滚动逻辑虽然多几行代码,但换来的是可控性和流畅度。原生 behavior: 'smooth' 看着省事,实则暗坑无数,尤其在复杂页面上。

这个方案不是最优解(比如没考虑 SSR、无障碍等),但对大多数场景已经够用。有更优的实现方式欢迎评论区交流,比如结合 CSS scroll-behavior 渐进增强,或者用 Web Workers 分离计算?我还没试过,但挺感兴趣。

以上是我踩坑后的总结,希望对你有帮助。

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

暂无评论