用CSS与JavaScript打造流畅自然的前端动画效果

子怡 Dev 移动 阅读 794
赞 1 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

上周我负责的移动端活动页上线后,用户反馈“滑动卡成PPT”“动画一出来手机就发烫”。我一开始还不信,自己拿iPhone 12 Pro Max测还行啊——直到借了同事的千元机跑了一下,好家伙,帧率直接掉到15fps,连点击按钮都有明显延迟。

用CSS与JavaScript打造流畅自然的前端动画效果

这个页面其实不算复杂:顶部有个视差滚动的背景图,中间是几个带入场动画的卡片(fade-in + slide-up),底部还有个随滚动渐变的导航栏。但组合起来在低端机上简直灾难。用户停留时间比平均低了快40%,显然体验太差直接跑了。

找到瓶颈了!

我先用 Chrome DevTools 的 Performance 面板录了个滚动过程。结果一看吓一跳:主线程被 JavaScript 占满,layout 和 paint 几乎每帧都在触发,GPU 使用率倒是不高——说明根本没用上硬件加速。

再切到 Layers 面板,发现那些动画元素居然都没独立合成层!全挤在同一个 layer 里,每次 opacity 或 transform 变化都要重排重绘整个文档。难怪卡。

另外,动画逻辑是用 scroll 事件监听写的,回调里直接操作 DOM 样式。低端机上 scroll 事件疯狂触发,主线程根本处理不过来。

核心代码就这几行(但改对了很关键)

折腾了半天,最后靠三个改动把性能拉回来了:

  • 把所有动画属性切换到 transformopacity(避免触发布局和绘制)
  • will-change: transform 提前声明合成层(但别滥用!)
  • 滚动监听改用 requestAnimationFrame 节流(别再直接绑 scroll 了)

下面直接上代码对比。这是优化前的“罪魁祸首”:

// 优化前:直接操作 top 和 opacity,滚动时疯狂重排
window.addEventListener('scroll', () => {
  const scrollTop = window.pageYOffset;
  document.querySelector('.card').style.top = ${scrollTop * 0.3}px;
  document.querySelector('.nav').style.opacity = Math.min(1, scrollTop / 100);
});

改成这样之后,帧率立马稳了:

// 优化后:只动 transform 和 opacity,并用 rAF 节流
let ticking = false;

function updateAnimations() {
  const scrollTop = window.pageYOffset;
  // 注意:这里用 translate3d 触发 GPU 加速
  document.querySelector('.card').style.transform = translate3d(0, ${scrollTop * 0.3}px, 0);
  document.querySelector('.nav').style.opacity = Math.min(1, scrollTop / 100);
  ticking = false;
}

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

对应的 CSS 也得配合:

/* 关键:只允许 transform 和 opacity 变化 */
.card,
.nav {
  /* 提示浏览器:这个元素会频繁变化,提前分层 */
  will-change: transform, opacity;
  /* 确保使用硬件加速 */
  transform: translate3d(0, 0, 0);
}

这里注意我踩过好几次坑:will-change 别乱加!加太多反而增加内存开销。只给真正需要动画的元素加,而且最好在动画开始前动态添加,结束后移除(不过这次项目简单,我就偷懒写死在 CSS 里了)。

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

第一,别以为用了 transform 就万事大吉。如果你的元素有 box-shadowborder-radius 或者半透明背景,GPU 渲染照样可能掉帧。这次我就把卡片的 box-shadow 换成了伪元素 + background-image 模拟,性能提升明显。

第二,requestAnimationFrame 节流不是万能的。如果回调函数里做了复杂计算(比如遍历大量 DOM),还是可能阻塞主线程。所以我在 updateAnimations 里只做最轻量的操作——提前缓存好 DOM 引用,别在回调里反复 querySelector

第三,低端安卓机对 will-change 支持不太稳。测试时发现某些机型反而更卡,后来查资料才知道有些老内核会把它当成普通样式处理。保险起见,我加了个 UA 判断,只在 iOS 和高版本安卓启用:

const shouldUseWillChange = /iPad|iPhone|iPod|Android.+Chrome/.test(navigator.userAgent);
if (shouldUseWillChange) {
  document.querySelector('.card').style.willChange = 'transform';
}

优化后:流畅多了

改完之后,千元机上滚动帧率稳定在 50fps 以上,动画再也不掉帧了。用户停留时间回升到正常水平,后台监控也没再报性能异常。

最关键的是,代码其实没多几行,但思路对了效果立竿见影。以前总想着上 GSAP 或 Framer Motion 这类库,其实原生 API 配合正确写法完全够用,还省了打包体积。

性能数据对比

我用同一台红米 Note 9 测了优化前后数据(连续滚动 10 秒取平均值):

  • 主线程占用:从 78% 降到 22%
  • Layout/Recalculation 时间:从平均 8ms/帧 降到几乎为 0
  • 动画完成时间:卡片入场动画从卡顿 2.3s 完成 → 流畅 0.6s 完成
  • 内存占用:因为减少了重绘,峰值内存降了约 15MB

虽然不是实验室级别的精确数据,但肉眼可见的流畅已经足够说明问题。

还有个小尾巴

其实还有一个小问题没彻底解决:首次进入页面时,如果网络慢,图片加载会导致 layout shift,进而影响动画初始位置。目前我是用占位图 + aspect-ratio 锁定容器尺寸缓解的,但不算完美。如果有更好的方案欢迎交流。

以上是我踩坑后的总结,希望对你有帮助。这种性能优化其实没那么玄乎,核心就是:少扰动主线程、多用合成属性、善用浏览器机制。下次遇到卡顿,先打开 Performance 面板看看,八成能找到突破口。

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

暂无评论