用JS实现高性能动画的实战技巧与避坑指南

东方涵舒 移动 阅读 1,272
赞 14 收藏
二维码
手机扫码查看
反馈

又踩坑了,滚动动画在手机上卡成PPT

前两天上线一个活动页,首页有个视差滚动的 banner,PC 上跑得好好的,结果一到安卓机上直接卡成 PPT。一开始以为是图片太大,压缩完发现还是不行。后来用 Chrome 远程调试看了下 FPS,最低掉到 12,这哪是动画,这是幻灯片放映。

用JS实现高性能动画的实战技巧与避坑指南

我第一反应是:是不是 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 玩法,欢迎评论区交流。我也想看看有没有更轻量的解法。

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

暂无评论