Smooth Scroll 实现原理与前端性能优化实战经验
优化前:卡得不行
上个月接手一个老项目,首页有个“锚点导航”功能,点击侧边栏的链接会平滑滚动到对应区块。听起来很基础对吧?但实际体验——简直灾难。我用 iPhone 12 测试,手指一碰链接,页面卡顿半秒才开始动,滚动过程中掉帧严重,甚至偶尔直接卡死。安卓机更惨,有些低端机直接白屏。
用户反馈也炸了:“点一下要等半天”“滚动像幻灯片”。我一开始以为是 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 分离计算?我还没试过,但挺感兴趣。
以上是我踩坑后的总结,希望对你有帮助。

暂无评论