用JS实现高性能动画的实战技巧与避坑指南
又踩坑了,滚动动画在手机上卡成PPT
前两天上线一个活动页,首页有个视差滚动的 banner,PC 上跑得好好的,结果一到安卓机上直接卡成 PPT。一开始以为是图片太大,压缩完发现还是不行。后来用 Chrome 远程调试看了下 FPS,最低掉到 12,这哪是动画,这是幻灯片放映。
我第一反应是:是不是 JS 动画太重了?毕竟用了 requestAnimationFrame 去计算偏移,每帧都在读 scrollTop 再 set transform。但逻辑其实很简单,不应该啊。于是开始一步步排查。
先试了节流,结果滚动不跟手
最开始想到的是节流(throttle),毕竟 scroll 事件在移动端触发频率太高,尤其是支持高刷的机型,动不动就 60fps 触发,JS 根本处理不过来。
于是我加了个 50ms 的节流:
function throttle(fn, delay) {
let timer = null
return function () {
if (timer) return
timer = setTimeout(() => {
fn.apply(this, arguments)
timer = null
}, delay)
}
}
window.addEventListener('scroll', throttle(() => {
const top = window.pageYOffset
el.style.transform = translateY(${top * 0.5}px)
}, 50))
结果更糟——手指都划出屏幕了,图片还在慢悠悠地动,用户体验直接崩盘。这里我踩了个坑:节流虽然降低了计算频率,但也让动画失去了实时性。尤其在 touchmove 过程中,用户感知非常明显。
后来试了下 16ms(相当于 60fps),稍微好点,但低端机上还是卡。说明这条路治标不治本。
折腾了半天发现,根本问题是 layout thrashing
后来我在 DevTools 里开了 Performance 面板录了一段滚动过程,一看吓一跳:每一帧都有“强制重排”(Forced Reflow)警告。展开看,罪魁祸首就是这一行:
const top = window.pageYOffset
你没看错,就是读 scrollTop 这个操作。看起来只是读个值,但实际上,浏览器为了返回准确的 scrollTop,可能会触发重排(尤其是在某些安卓 WebView 中)。而我每帧都在读,等于每帧都在潜在地触发 layout thrashing。
再加上后面紧接着设置 transform,虽然这个是合成层操作,但如果前面已经重排了,那整个渲染管线就被拖慢了。
这里注意,我踩过好几次坑:**不要在高频事件中读写布局相关属性**,比如 offsetTop、scrollTop、clientWidth 等,它们都是“脏”的,会触发回流。
核心代码就这几行
最终解决方案是:把 scrollTop 的读取和 transform 的更新拆开,用 passive event + RAF 双缓冲机制。
思路是:
- 用 passive 的 scroll 事件只负责记录 scrollTop
- 然后用 requestAnimationFrame 在下一帧统一处理动画更新
- 这样避免了每帧读写交错导致的重排
完整代码如下:
let currentScrollTop = 0
let ticking = false
function updateParallax() {
// 这里只执行一次 transform 更新
el.style.transform = translateY(${currentScrollTop * 0.5}px)
ticking = false
}
function onScroll() {
// 被动事件中只记录值,不干活
currentScrollTop = window.pageYOffset
// 使用 RAF 防抖
if (!ticking) {
requestAnimationFrame(updateParallax)
ticking = true
}
}
// 关键:加上 passive: true,否则 iOS Safari 会阻止默认行为警告
window.addEventListener('scroll', onScroll, { passive: true })
就这么几行,FPS 直接从 12 拉到 50+,低端机也能流畅跑。而且滚动跟手性完全保留,用户滑多快,动画就跟多快。
谁更灵活?谁更省事?
其实还有别的方案,比如 Intersection Observer 或 CSS will-change,但我这次没用,原因如下:
- Intersection Observer:适合元素进入/离开视口时触发动作,但不适合持续变化的视差动画,控制粒度太粗
- CSS-only 方案:像 clip-path 或 background-attachment: parallax,兼容性太差,尤其安卓 WebView 基本不支持
- Web Animations API:理论上可以,但调试困难,报错信息不友好,项目赶进度的时候不敢碰
所以最后还是回到了 JS + RAF 这个老组合。别看它原始,只要用对方式,性能一点不输 fancy 的新语法。
踩坑提醒:这三点一定注意
这次改完后,还留了一个小问题:快速上下滚动时,偶尔会有轻微的“顿挫感”。查了下是因为 RAF 在某些场景下会被延迟,比如页面有 heavy painting 的时候。但这不影响大局,毕竟视觉上不再卡顿了。
总结一下我踩过的坑:
- 不要在 scroll 事件里直接读 layout 属性,哪怕只读也会触发重排
- passive event 必须加,不然 iOS 会 warning,甚至禁用滚动优化
- RAF 不是万能的,要配合双缓冲模式使用,避免重复注册
另外一个小技巧:如果你的动画元素是图片或独立容器,建议加上 transform: translateZ(0) 或 will-change: transform,让浏览器提前升級到 GPU 合成层:
.parallax-item {
will-change: transform;
/* 或者 */
transform: translateZ(0);
}
不过 will-change 别乱用,多了反而降低整体性能。
fetch 例子只是演示,别当真
顺便说一句,之前有同事问我能不能用 Web Worker 处理 scroll 数据,理论上可以,但实际没必要。因为 scroll 事件不能传给 Worker(跨线程通信不支持 Event 对象),而且数据量小,来回 postMessage 的成本更高。
倒是有种极端情况可以用:比如你要结合后端数据动态调整动画参数。例如根据用户设备等级请求不同精度的动画配置:
fetch('https://jztheme.com/api/animation-config', {
method: 'POST',
body: JSON.stringify({ deviceScore: window.devicePixelRatio * (navigator.hardwareConcurrency || 1) })
})
.then(res => res.json())
.then(config => {
animationFactor = config.parallaxRatio
})
但这种情况极少,大多数时候本地算就行。
以上是我踩坑后的总结,希望对你有帮助
这个方案不是最优的,但最简单,也最可控。改完上线后目前没收到卡顿反馈。当然也可能是因为用户懒得投诉……
如果你有更好的实现方式,比如纯 CSS 黑魔法或者新的 API 玩法,欢迎评论区交流。我也想看看有没有更轻量的解法。

暂无评论