一个真实的卡顿问题
2024 年 3 月,我在一个移动端 H5 活动页(用户量 200 万+)中接到反馈:iOS 低端机滚动时页面一顿一顿的,甚至直接白屏 1-2 秒。用 Chrome DevTools 远程调试 iPhone 8,Performance 面板显示 每个滚动帧都触发了强制同步布局(Forced Reflow),FPS 掉到 12 左右。
问题根源:页面上有 80 多个具备 CSS 过渡动画的卡片,滚动时不断修改 left、width 等几何属性,导致浏览器在每一帧都要执行完整的 Layout → Paint → Composite。
浏览器渲染流水线原理(一张图讲清)
渲染流水线共 6 步,但只有前 4 步会触发同步开销:
- DOM Tree(解析 HTML 生成)
- CSSOM Tree(解析 CSS 生成)
- Render Tree(DOM + CSSOM 合并,只包含可见节点)
- Layout(计算几何尺寸和位置)
- Paint(绘制像素)
- Composite(合成图层,GPU 加速)
注意:JavaScript 修改 DOM 或 CSSOM 后,浏览器会标记对应元素为 dirty,下一帧开始时执行 “reflow” 或 “repaint”。如果 JS 在同一个函数里连续读写几何属性(如 offsetHeight → 改 style → 再读 offsetHeight),就会强制浏览器立即执行 Layout,叫做 Forced Reflow。
一个表格对比不同 CSS 属性触发的流水线阶段:
| CSS 属性 | 触发阶段 | 重排? | 重绘? | 合成? |
|---|---|---|---|---|
| left, top, width, height | Layout → Paint → Composite | ✅ | ✅ | ✅ |
| color, background-color | Paint → Composite | ❌ | ✅ | ✅ |
| transform, opacity | Composite | ❌ | ❌ | ✅ |
| will-change | Composite(提前创建图层) | 视属性而定 | 视属性而定 | ✅ |
方案对比:3 种常见优化方法
针对滚动卡顿,我尝试了三个方案,逐一压测:
- 方案A: 用
will-change: transform, opacity告诉浏览器提前创建合成层。 - 方案B: 用
transform: translateX()代替left实现动画。 - 方案C: 用
requestAnimationFrame批量读写几何属性,避免强制同步布局。
测试环境:Chrome 124 / iPhone 8 (iOS 16.6) / 模拟 80 个带动画的元素。测试工具:Chrome DevTools Performance 记录 5 秒滚动,统计 FPS、Total Blocking Time(TBT)、Layout 次数。
方案A:will-change 的实际效果与坑
/* 给每个动画元素加上 */
.card {
will-change: transform, opacity;
}
结果:FPS 从 12 提升到 28,但仍然低于 60。Perf 面板显示 图层数量暴增 80+,内存占用从 120MB 飙升到 350MB(iOS 低端机直接崩溃)。
原理:will-change 让浏览器为该元素创建独立的合成层,但创建过多图层会导致 GPU 显存压力和合成开销。所以不能滥用。
方案B:transform 替代 left
/* 原来 */
@keyframes slide {
from { left: 0; }
to { left: 200px; }
}
/* 改为 */
@keyframes slideTransform {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}
结果:FPS 提升到 45,Layout 次数从 1200 次降为 80 次(因为 transform 只触 Composite,不触 Layout)。但页面依然偶尔掉帧,原因在于滚动事件中读取了 offsetTop 触发了强制同步布局。
方案C:requestAnimationFrame + 读写分离(最终方案)
核心思路:将所有几何属性的读取集中在一个 rAF 回调的开头,写入集中在下一个回调,避免在一个函数里混合读写。
// 错误写法 —— 导致密集的强制同步布局
function onScroll() {
const left = card.offsetLeft; // 读取 → 触发 Layout(如果前面有未生效的写入)
card.style.left = (left + 1) + 'px'; // 写入 → 标记 dirty
const width = card.offsetWidth; // 再次读取 → 立即执行 Layout(Forced Reflow)
card.style.width = (width * 1.1) + 'px';
}
// 正确写法 —— 读写分离
const pendingWrites = [];
function readPhase() {
// 集中读取所有几何值,此时不会触发 Layout,因为尚未有未执行的写入
pendingWrites.length = 0;
cards.forEach(card => {
const left = card.offsetLeft; // 安全读取
const width = card.offsetWidth; // 安全读取
pendingWrites.push({ card, left: left + 1, width: width * 1.1 });
});
requestAnimationFrame(writePhase);
}
function writePhase() {
// 一次写入所有修改,下一帧才会开始布局
pendingWrites.forEach(({ card, left, width }) => {
card.style.left = left + 'px';
card.style.width = width + 'px';
});
// 如果不需要连续动画,可在此处结束循环
}
// 首次启动
readPhase();
进一步优化:使用 transform: translateX() + 读写分离的组合。
function optimizedScrollHandler() {
const scrollTop = window.scrollY; // 读取不会触发 Layout
cards.forEach(card => {
const offset = scrollTop * card.dataset.speed; // 计算偏移量,不涉及 DOM 读取
card.style.transform = `translateY(${offset}px)`; // 只触 Composite
});
}
// 用 passive: true 避免浏览器等待阻止默认行为
window.addEventListener('scroll', optimizedScrollHandler, { passive: true });
效果数据(关键对比)
| 方案 | FPS(平均) | Layout 次数(5s) | TBT(ms) | 内存峰值(MB) |
|---|---|---|---|---|
| 原始代码(left+直接读写) | 12 | 1,234 | 3,800 | 120 |
| 方案A(will-change) | 28 | 1,100 | 2,100 | 350 |
| 方案B(transform代替left) | 45 | 80 | 400 | 130 |
| 方案C(transform + 读写分离 + passive) | 58 | 3 | 90 | 105 |
最终上线后,iOS 低端机 FPS 稳定在 55-60,白屏问题消失。Lighthouse 性能评分从 38 分升至 92 分。
避坑指南(4 个真实踩过的坑)
坑1:读到老数据导致的死循环
一开始我在 writePhase 里又读了一下某个元素的 offsetHeight 来微调,结果又触发了 Layout,导致无限循环。一定要把读取和写入完全隔离到不同的 rAF 回调,中间不能有 DOM 读写。
坑2:will-change 的内存爆炸
给 80 个元素加 will-change: transform, opacity 后,Chrome 在合成层分配了大量 GPU 内存,页面直接 OOM 崩溃。后来改为只对即将动画的元素动态添加,动画结束后移除。
// 开始动画前
element.style.willChange = 'transform, opacity';
// 动画结束后(比如 transitionend 事件)
element.addEventListener('transitionend', () => {
element.style.willChange = 'auto';
});
坑3:passive 事件监听器的兼容性
iOS Safari 12 以下不支持 { passive: true },直接报错导致滚动失效。我用特性检测回滚:
let supportsPassive = false;
try {
const opts = Object.defineProperty({}, 'passive', {
get() { supportsPassive = true; return false; }
});
window.addEventListener('test', null, opts);
} catch(e) {}
window.addEventListener('scroll', handler, supportsPassive ? { passive: true } : false);
坑4:transform 与固定定位的冲突
有个 fixed 定位的购物车按钮,用 transform: translateY() 后,它的父级也用了 transform,导致 fixed 相对于最近的 transform 父级定位,而不是 viewport。解决方法:把 fixed 元素单独放在 body 下,并且不要有任何 transform 祖先。
/* 错误 */
.parent { transform: translateZ(0); }
.fixed-btn { position: fixed; top: 10px; } /* 现在相对于 .parent,不是 viewport */
/* 正确 */
body > .fixed-btn { position: fixed; top: 10px; }
总结(一句话)
渲染优化=理解流水线+几何属性用 transform+读写分离+图层管理+passive 事件。