前端性能优化实战中的关键技巧与避坑指南

UX维通 框架 阅读 1,982
赞 30 收藏
二维码
手机扫码查看
反馈

又踩坑了,页面滚动直接卡成PPT

今天上线前最后测一遍性能,打开 Chrome DevTools 的 Performance 面板一录,好家伙,手指一滑页面,FPS 直接掉到 12,主线程跑满,长任务一个接一个。这哪是 H5 页面,这是幻灯片播放器吧。

前端性能优化实战中的关键技巧与避坑指南

项目是个活动页,有很多动态入场动画 + 滚动视差 + 图片懒加载。之前一直觉得“反正就一次性的营销页,凑合能用就行”,结果这次甲方爸爸用了台老款安卓机测试,直接崩了——滑两下就白屏,touchmove 回调里一堆计算全塞在主线程,根本来不及响应。

一开始以为是图片太多,疯狂优化 Image

第一反应:肯定是图片没处理好。于是我把所有 <img> 都加上 loading="lazy",又把大图换成 WebP,CDN 开启自动压缩,甚至上了 IntersectionObserver 做手动懒加载。改完本地看确实轻快了点,但一上真机,还是卡。

后来用 Performance 录了一段,发现瓶颈压根不在渲染或解码图片,而是在 touchmove 事件回调里有个叫 updateParallaxOffset 的函数,每一帧都在做 DOM 查询和样式计算,还频繁触发重排(layout thrashing)。

这里我踩了个坑:之前为了省事,直接在 touchmove 里写:

document.addEventListener('touchmove', function(e) {
  const scrollTop = window.pageYOffset;
  const elements = document.querySelectorAll('.parallax');
  elements.forEach(el => {
    const speed = parseFloat(el.getAttribute('data-speed'));
    el.style.transform = translateY(${scrollTop * speed}px);
  });
});

看着挺正常,但实际上 querySelectorAll 在每次 touchmove 都执行,加上 style.transform 虽然不触发重排,但前面如果读过 offsetTopgetBoundingClientRect(),浏览器就会强制回流。而我在别的地方确实读了这些值来做动画判断……折腾了半天才发现是这个混合操作导致的连锁反应。

试了三种方案,最后选了个最土但最稳的

第一种方案:节流。我加了个 throttle(16ms),理论上每 60fps 一更新:

function throttle(fn, delay) {
  let last = 0;
  return function(...args) {
    const now = Date.now();
    if (now - last >= delay) {
      fn.apply(this, args);
      last = now;
    }
  };
}

document.addEventListener('touchmove', throttle(updateParallaxOffset, 16));

结果问题来了:节流后虽然 CPU 占用降了,但视差效果明显断层,滑动不连贯,尤其在快速滚动时有“跳帧”感。用户体验还不如原来卡着流畅。

第二种方案:requestAnimationFrame。改成这样:

let ticking = false;

document.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      updateParallaxOffset();
      ticking = false;
    });
    ticking = true;
  }
});

这个其实更适合 scroll 事件,但问题是 touchmove 不一定触发 scroll,特别是在一些低端 Android 浏览器里,scroll 事件有延迟或者合并行为,导致视差滞后严重。实测下来还不如节流稳定。

第三种方案:直接上 CSS transform + will-change + 分离计算逻辑。这才是正道。

核心代码就这几行,但原理得说清楚

最终思路是:把所有依赖滚动位置的视觉变化,从 JavaScript 移到 CSS 层面,用 GPU 加速接管。同时避免在 touch 相关事件中做任何 DOM 查询或布局读取。

先预处理所有需要视差的元素,在页面加载时缓存它们的位置和参数:

const parallaxElements = [];

document.querySelectorAll('[data-parallax]').forEach(el => {
  const rect = el.getBoundingClientRect();
  parallaxElements.push({
    node: el,
    baseTop: rect.top + window.pageYOffset,
    speed: parseFloat(el.getAttribute('data-parallax')) || 0.5
  });
  // 提升图层,启用 GPU 加速
  el.style.transform = 'translateZ(0)';
  el.style.willChange = 'transform';
});

然后监听 scroll 事件(不是 touchmove!),配合 requestAnimationFrame 控制更新频率:

let latestScrollY = window.pageYOffset;

function updateParallax() {
  const currentScroll = latestScrollY;
  parallaxElements.forEach(item => {
    // 计算偏移量,但只改 transform
    const distance = currentScroll - item.baseTop;
    const offset = distance * item.speed;
    item.node.style.transform = translate3d(0, ${offset}px, 0);
  });
}

window.addEventListener('scroll', () => {
  latestScrollY = window.pageYOffset;
  requestAnimationFrame(updateParallax);
});

注意这里的关键点:

  • 不在事件中直接操作 DOM 查询或读取 layout 信息
  • 使用 translate3d 强制走 GPU 合成层
  • 通过 will-change 提示浏览器提前创建合成层(不过别滥用)
  • 把 scroll 值缓存起来,交给 rAF 统一处理,避免重复计算

改完之后,Performance 面板显示主线程空了很多,FPS 稳定在 50 以上,老机型也能接受。

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

1. 不要以为 transform 就一定不重排——如果你在同一个 tick 里先读 offsetTop 再写 transform,浏览器还是会强制同步 layout。必须彻底拆开读写阶段。

2. will-change 别乱加。我一开始给几十个元素都加了 will-change: transform,结果内存飙升,页面变慢。后来只加在真正频繁动画的几个元素上,才平衡好性能和资源占用。

3. 安卓低端机对合成层数量有限制。有的 WebView 最多支持 8~12 个合成层,超出的部分会 fallback 到软件渲染。所以不要以为加了 translateZ 就万事大吉,得控制数量。

还有个小尾巴没解决

现在的问题是,首屏刚进来的时候,某些元素位置还没稳定,getBoundingClientRect() 拿到的是错的,导致初始偏移不准。我的 workaround 是在 window.onload 后再初始化 parallax 元素列表,稍微延迟一下:

window.addEventListener('load', () => {
  setTimeout(initParallaxElements, 100);
});

虽然糙了点,但至少比原来强。更优雅的做法可能是监听 ResizeObserver 或等关键元素加载完成,但这块投入产出比不高,先放着了。

总结一下

这次优化让我重新认识到:前端性能不是“加个懒加载就完事”,而是要通盘考虑事件频率、DOM 操作粒度、渲染管线衔接。尤其是移动端,不能拿 PC 上的表现去推测真实体验。

最有效的手段往往最朴素:减少 JS 对 DOM 的侵入式操作,把动画交给 CSS 和 GPU,用好 rAF 和事件批处理。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。比如有没有人试过用 Web Animations API 或者 CSS @scroll-timeline 来做这类效果?我也在关注,只是目前兼容性太差,暂时不敢上生产。

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

暂无评论