学完这篇你能拿到一份可直接照着改的清单:知道哪些 DOM 写法会触发重排重绘,以及每种写法对应的替代方案。
第一步:先分清重排、重绘、合成
三件事的代价差一个数量级:
- 重排(layout):元素的几何属性变了——宽高、位置、字体大小、内容增减、增删节点。浏览器要重新计算布局,代价最高。
- 重绘(paint):只改颜色、背景、阴影、边框色,布局没变,跳过 layout 直接画。
- 合成(composite):只改
transform、opacity,连重绘都不用,交给 GPU 图层合成,最便宜。
浏览器本身会攒一批改动,等到一帧快结束时统一算一次。真正的性能问题是:你主动打断这个攒批过程,逼它提前算。
第二步:读写分离,消灭布局抖动
最常见的性能问题出在循环里一边写样式一边读布局:
// 坏:每次读 offsetWidth 都强制同步布局一次
for (const el of items) {
el.style.width = el.offsetWidth + 10 + 'px';
}
el.style.width = ... 只是把改动标记为脏,但下一行读 offsetWidth 时,浏览器为了让返回值准确,必须立刻把刚才的改动算完。1000 个元素就是 1000 次强制布局。
改成先把要读的一次读完,再统一写:
const widths = items.map(el => el.offsetWidth);
items.forEach((el, i) => {
el.style.width = widths[i] + 10 + 'px';
});
注意:offsetTop / offsetLeft / offsetWidth / offsetHeight、scrollTop / scrollHeight、clientWidth / clientHeight、getBoundingClientRect()、getComputedStyle().width、focus()、scrollIntoView() 都会强制布局。看到这些属性出现在写样式的下一行,就要警惕。
第三步:样式改动合并成一次
// 坏:三次属性写,可能触发多次样式重算
el.style.width = '100px';
el.style.height = '50px';
el.style.margin = '10px';
// 好:一次写完
el.className = 'card card--active';
// 或者
el.style.cssText += ';width:100px;height:50px;margin:10px';
优先用 class 切换,样式逻辑留在 CSS 里,改起来也好维护。
注意:直接赋值 className 会把元素原有的类名整个覆盖掉。用 classList.add/remove/toggle,或者先把完整类名字符串拼好再赋值。
第四步:批量插入节点前,先让它脱离文档
往已经挂载到页面上的容器里循环 appendChild,每插一次都可能触发一次重排。三种常用解法:
// 方案一:DocumentFragment,最后一次性挂载
const frag = document.createDocumentFragment();
for (const d of data) frag.appendChild(createItem(d));
list.appendChild(frag); // 只触发一次重排
// 方案二:容器先隐藏,改完再显示
list.style.display = 'none';
// ...大量 DOM 操作
list.style.display = '';
// 方案三:整体替换 innerHTML(数据量大时最快)
list.innerHTML = data.map(d => `<li>${escapeHtml(d.title)}</li>`).join('');
注意:方案三务必对插值做 HTML 转义,否则就是 XSS 漏洞。用户可控的内容一律走 textContent 或转义函数。
第五步:动画只动 transform 和 opacity
// 坏:每帧改 left,每帧重排
el.style.left = x + 'px';
// 好:每帧改 transform,只走合成
el.style.transform = `translateX(${x}px)`;
需要提前提升图层时可以加 will-change: transform,但要在动画开始前加、结束后移除。
注意:will-change 加在大量元素上会导致图层数量暴涨、显存吃紧,反而更卡。它适合少量需要频繁动画的元素,不是万能优化。
第六步:减少节点数量和嵌套层级
布局计算量和节点数正相关,几千行的列表直接全量渲染一定会卡。
- 长列表用虚拟滚动,只渲染视口内的几十行
- 给屏幕外的大块区域加
content-visibility: auto,跳过渲染
- 给独立组件加
contain: layout paint,把重排影响范围限制在容器内
- 别用
table 做布局,别为了样式过度嵌套 div
第七步:事件委托代替逐个绑定
// 1000 个按钮绑 1000 个监听器
list.addEventListener('click', e => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
handle(btn.dataset.action);
});
一个监听器搞定所有子元素,初始化开销和内存占用都降下来,动态插入的新节点也自动生效,不用再补绑定。
第八步:滚动相关逻辑别写在 scroll 回调里
需要读布局、改样式的操作放进 requestAnimationFrame:
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
updateSomething();
ticking = false;
});
});
懒加载图片、吸顶导航、滚动入场动画这类需求,优先用 IntersectionObserver,浏览器内部做了优化,比监听 scroll 再算位置便宜得多。
第九步:用 DevTools 验证,别靠猜
- Performance 面板:录制一段操作,看时间轴上有没有大片紫色 Layout,以及黄色的 "Forced reflow" 警告——那就是布局抖动的直接证据
- Rendering 面板:勾选
Paint flashing 看哪里在重绘,勾选 Layout Shift Regions 看布局变化范围
- Elements 面板:选中元素看它的 Layers 信息,确认动画元素是否真的被提升为独立图层
改完之后录一次对比,帧耗时从多少降到多少,用数字说话。
小结
- 重排 > 重绘 > 合成,动画优先用
transform / opacity
- 读写分离:先把
offsetWidth 这类布局属性一次读完,再统一写样式,避免布局抖动
- 样式改动合并成一次,优先切 class 而不是逐条改
style
- 批量插入用
DocumentFragment,或先隐藏容器再操作
- 事件委托 +
IntersectionObserver + requestAnimationFrame,别在 scroll 回调里读布局
will-change 和 content-visibility 是针对性手段,不是无脑加
- 优化前后都用 Performance 面板录一遍,看 "Forced reflow" 数量有没有下降