前端性能优化实战中的关键技巧与避坑指南
又踩坑了,页面滚动直接卡成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 虽然不触发重排,但前面如果读过 offsetTop 或 getBoundingClientRect(),浏览器就会强制回流。而我在别的地方确实读了这些值来做动画判断……折腾了半天才发现是这个混合操作导致的连锁反应。
试了三种方案,最后选了个最土但最稳的
第一种方案:节流。我加了个 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 来做这类效果?我也在关注,只是目前兼容性太差,暂时不敢上生产。

暂无评论