症状
性能轨迹就是案发现场:长条紫色 Layout 与零碎 script 交替出现、每个滚动事件重复一轮;控制台补上自白——“Forced reflow while executing JavaScript took 42ms” 并指向触发读取的那一行。经典源码形态是循环里“读 top、写 transform、再读 top”:写使布局缓存失效,下一次读强制同步重算,每次迭代付一次完整布局,数百元素乘以每秒数十次事件。滚动卡顿、resize 期间掉帧,都是这个机制的直接体验。
诊断思路
先录 Performance 看紫色 Layout 条与脚本片的交错形状;再开强制重排警告拿到触发行号;最后用代理计数 getBoundingClientRect 调用,量化单个滚动事件的读取放大倍数。用 top/left 做动画、框架渲染循环里边读 DOM 边改样式,是两个高频变体。
根因
浏览器缓存布局并惰性重算。样式写入使缓存失效,下一次几何读取强制同步重算。读写交错让每个周期都付一次完整布局;叠加到每个滚动事件上,本来的缓存毫秒读变成撕碎帧率的工作。
修复与验证
分相批处理:先完成全部读(数组缓存测量值),再执行全部写,两相之间互不穿插;事件驱动的工作用 requestAnimationFrame 节流到帧预算(配 passive 监听器);动画只走合成器路径——transform 与 opacity,top/left/width/height 按定义每帧都要付布局。验证时重录滚动轨迹:Layout 条缩成偶尔的小块、强制重排警告消失、帧率守住显示预算;若迁移到 transform 定位,记得复核遮罩层与层叠语义。
代码示例
[script 0.4ms] [Layout 18ms] [script 0.3ms] [Layout 17ms] ...
items.forEach((el) => {
const top = el.offsetTop; // read -> forces layout after...
el.style.transform = `translateY(${top}px)`; // ...this write invalidated it
});
function update() {
// read phase: no writes in between
const tops = items.map((el) => el.offsetTop);
// write phase: no reads in between
items.forEach((el, i) => {
el.style.transform = `translateY(${tops[i]}px)`;
});
}
let queued = false;
window.addEventListener('scroll', () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { update(); queued = false; });
}, { passive: true });