js 操作 DOM 为什么会影响性能
JS 操作 DOM 慢源于跨引擎绑定调用及修改触发重排重绘,真正拖慢页面的是循环中读写交替导致的强制同步布局;应缓存布局读取、先读后写批量更新,并优先用 transform 和 opacity 做动画。
JS 操作 DOM 之所以慢,是两件事叠加的结果:一是 DOM 节点不是 JS 原生对象,每次读写都要跨引擎边界走绑定层(binding)调用;二是修改 DOM 会让浏览器的样式计算、布局、绘制失效,触发重排(reflow,重新计算元素几何位置)和重绘(repaint,重新画像素)。结论:真正拖慢页面性能的,主要不是 DOM API 本身,而是像在循环里读写交替这种会触发「强制同步布局」的写法;一次 DOM 赋值可以在 1 帧内合并,一次多余的同步布局却要立刻算完。
为什么 DOM 操作比普通 JS 对象操作慢
结论:DOM 节点是渲染引擎(Chrome 的 Blink、Firefox 的 Gecko)里的 C++ 对象,V8 只能通过包装对象(wrapper)和绑定层去访问,每次属性读写都是一次跨语言调用。
普通 JS 对象的属性访问是几纳秒到几十纳秒级,而读写 DOM 属性要先进绑定层、做类型转换、再回到 JS,通常在百纳秒到微秒级,比同结构普通对象的读写慢 10 倍以上。这也解释了为什么把 let h = el.offsetHeight 缓存在变量里、循环里反复用,比每次重新读 el.offsetHeight 快得多:省下的不是 JS 运算,而是成百上千次跨引擎调用。
哪些操作会触发重排,哪些只触发重绘
结论:改几何相关的属性触发重排,改颜色和背景只触发重绘,改 transform 和 opacity 只触发合成(composite),三者的成本依次低一个数量级。
- 触发重排:修改
width、height、margin、padding、border-width、font-size、display、position、top/left,以及增删 DOM 节点。 - 只触发重绘:修改
color、background-color、visibility、box-shadow。 - 只触发合成:修改
transform、opacity、filter,这类属性由合成线程处理,不阻塞主线程的布局计算。
所以做位移动画时用 transform: translateX() 而不是 left,60fps 下差别是「每帧重排一次整页」和「每帧只提交一个合成层」的区别。
为什么在循环里读写 DOM 会特别慢
结论:浏览器本来会把布局推迟到每帧末尾批量做一次,但只要你在写入之后读取 offsetWidth、offsetTop、getBoundingClientRect()、getComputedStyle() 这类依赖布局的值,浏览器就必须立刻把布局算完才能返回准确结果;这叫强制同步布局(layout thrashing,读写交替导致的布局抖动)。
// 反面写法:100 次循环 = 100 次强制同步布局
for (let i = 0; i < 100; i++) {
box.style.width = box.offsetWidth + 1 + 'px';
}
// 正确写法:先读后写,只触发 1 次布局
let w = box.offsetWidth;
for (let i = 0; i < 100; i++) { w += 1; }
box.style.width = w + 'px';
触发强制布局的读取清单要记住:offsetTop/offsetLeft/offsetWidth/offsetHeight、scrollTop/scrollLeft/scrollHeight、clientWidth/clientHeight、getBoundingClientRect()、getComputedStyle()、focus()。规则只有一条:同一个帧内,把所有读操作集中在前面,把所有写操作集中在后面。
减少 DOM 操作的具体做法有哪些
结论:核心思路是把 N 次分散的 DOM 变更合并成 1 次,把 N 次重排压缩到 0 到 1 次。
- 用 DocumentFragment 批量插入:要插入 1000 个节点,先在 fragment 上
appendChild1000 次(不触发重排),最后container.appendChild(fragment)只触发 1 次。直接对文档中的父节点 append 1000 次,浏览器虽然会合并到帧末,但每次插入仍要走一遍 DOM 树挂载和样式失效标记。 - 离线改完再上树:
cloneNode(true)在内存里改完再替换,或者先display: none改完再恢复;元素不在渲染树里时不参与布局计算。 - 合并样式写入:能用一个
class切换搞定的,不要连续写 5 条el.style.xxx;纯文本更新用textContent而不是innerHTML,避免 HTML 解析器介入。 - 事件委托:1000 个列表项各绑一个监听器改成父节点绑 1 个,初始化和内存占用同时下降。
- 动画用
transform和opacity,配合requestAnimationFrame在帧回调里改,不要用setTimeout抢跑。 - 长列表用虚拟滚动:只渲染可视区 20 到 30 个节点,而不是一次渲染 5000 个。
- 滚动场景用
IntersectionObserver替代在scroll回调里读scrollTop做判断,后者是典型的强制同步布局来源。
现代浏览器做了哪些优化
结论:浏览器确实把布局线程化、把合成独立出来了,但「读写交替」这条规则没变。Chrome 用 LayoutNG 重写布局引擎,transform 和 opacity 动画跑在合成线程上不占主线程;content-visibility: auto 可以让屏幕外的内容跳过布局和绘制;contain: layout paint 能把一个子树的重排范围限制在容器内。使用前注意兼容性:content-visibility 在 Chrome 85 及以上支持,contain 在 Chrome 52 及以上支持。这些手段只是缩小重排范围,不能消除强制同步布局。
回到开头:JS 操作 DOM 的代价,一层来自跨引擎调用,一层来自重排重绘。前者靠缓存属性值解决,后者靠「读集中、写集中、能合成就不重排、能批量就不逐个」解决。把这三条落实到代码里,一个中大型列表页从每帧几十毫秒的布局耗时降到几毫秒是常见的量级变化。
转载请注明出处,版权归原作者所有。