DOM操作慢的根本原因是重排和重绘,重排重新计算元素几何信息并必然触发重绘,重绘仅改变外观;读写布局属性交替会强制同步重排,用DocumentFragment批量插入可大幅减少重排次数。

DOM操作慢,根本原因不是JavaScript本身,而是每次修改都可能触发浏览器重排(reflow)和重绘(repaint)——这两步是渲染流水线中最耗资源的环节。重排比重绘代价高得多,而很多看似“只是改个颜色”的代码,其实悄悄引发了重排。
什么是重排(reflow)和重绘(repaint)?
重排是浏览器重新计算所有元素几何信息(位置、尺寸)的过程;只要改动了 width、height、top、display、font-size 或增删节点,就大概率触发重排。重绘则只发生在外观变化但布局不变时,比如改 color、background-color、opacity。
关键点:重排必然触发重绘,但重绘不一定触发重排。频繁重排会让页面卡顿,尤其在中低端设备上明显。
- 常见误触重排的操作:
offsetTop、clientWidth、getComputedStyle()等读取布局属性时,如果之前有未应用的样式写入,浏览器会强制同步刷新(forced synchronous layout) - 一个
for循环里边读边写,很容易变成“写→读→写→读…”的恶性循环,每轮都强制重排
用 DocumentFragment 批量插入节点
这是最直接、见效最快的优化手段:把多次 DOM 插入合并成一次,把 100 次重排压成 1 次。
立即学习“Java免费学习笔记(深入)”;
错误写法(每轮都触发重排):
const list = document.getElementById('list');
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
list.appendChild(li); // ❌ 每次都插入真实DOM
}
正确写法(只触发 1 次重排):
const list = document.getElementById('list');
const fragment = document.createDocumentFragment(); // ✅ 内存中的虚拟容器
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = `Item ${i}`;
fragment.appendChild(li); // ✅ 全部塞进fragment,不触发重排
}
list.appendChild(fragment); // ✅ 一次性上树
-
DocumentFragment不在真实 DOM 树中,对它的操作完全不触发渲染 - 适用于动态生成列表、表格、表单项等场景
- 注意:不能用
innerHTML赋值给 fragment,它不支持该属性;必须用appendChild或append
用 class 切换代替逐条改 style
直接赋值 element.style.width = '200px' 会强制浏览器同步计算样式,极易引发重排;而切换预设好的 CSS 类,由浏览器批量处理,更可控也更高效。
CSS 中定义:
.highlight {
background-color: #ffeb3b;
font-weight: bold;
transform: scale(1.05);
}
JS 中只需:
element.classList.add('highlight'); // ✅ 单次操作,且 transform 不触发重排
- 避免这样写:
element.style.backgroundColor = '...'; element.style.fontWeight = '...';—— 多次写入,可能多次重排 - 优先使用
transform和opacity动画属性,它们走合成层(GPU),跳过 Layout 和 Paint 阶段 - 慎用
will-change,它虽可提示浏览器提前优化,但滥用会吃内存,只在明确要动画的元素上加
缓存查询结果 + 事件委托防爆栈
重复调用 document.getElementById 或 querySelector 不是“小开销”,而是每次都要遍历 DOM 树。更危险的是为每个子元素绑定事件监听器,100 个按钮 = 100 个监听函数,内存和性能双拖累。
- 缓存 DOM 引用:
const btn = document.querySelector('#submit');查一次,后面全用这个变量 - 事件委托:把监听器挂在父容器上,靠
e.target判断来源
document.getElementById('item-list').addEventListener('click', function(e) {
if (e.target.matches('button.delete')) { // ✅ 只绑1个监听器
e.target.closest('li').remove();
}
});
这招在动态增删子项的列表、评论区、弹窗组件里特别管用——不用每次新增都手动绑定事件,也不怕节点被移除后监听器残留。
真正容易被忽略的,是“读写分离”:别在循环里一边改样式一边读 offsetHeight;先把所有写操作做完,再统一读。否则,你写的每一行 JS,都在悄悄让浏览器停下主线程、重跑整个渲染流程。











