DOM(Document Object Model)是 JavaScript 与页面交互的桥梁,也是前端性能问题最集中的"事故高发区"。一次不小心的循环插入、一次无意识的属性读取、一次同步布局的读写交错,都可能让一个本来流畅的页面变成"幻灯片"。本文将从浏览器渲染原理出发,系统讲解 DOM 操作的成本来源、批量更新策略、事件委托、重排与重绘的规避、requestAnimationFrame 调度、虚拟 DOM 思想、现代观察者 API,以及一整套可落地的性能优化方案。45分钟沉浸式学习,帮你彻底驯服 DOM,写出真正高性能的前端代码。
为什么 DOM 操作这么慢
DOM 节点并不是普通的 JavaScript 对象。它们由浏览器的渲染引擎(如 Blink、WebKit)用 C++ 实现,JavaScript 只能通过绑定层访问。每一次跨语言边界的调用、每一次属性访问,都可能产生额外的开销。
const el = document.querySelector('.list');
/* 每次读取都可能触发样式计算 */
const w = el.offsetWidth;
const h = el.offsetHeight;
/* 一次写入,可能触发重排 + 重绘 */
el.style.width = w + 'px';
el.style.height = h + 'px';
/* 最糟糕的写法:读写交错(强制同步布局) */
for (let i = 0; i < items.length; i++) {
items[i].style.width = container.offsetWidth + 'px';
}
减少步骤、合并步骤,就是优化的本质
缓存节点引用,杜绝重复查询
DOM 查询是最常见的性能浪费来源。同一个元素被反复查询,每次都要遍历 DOM 树。把查询结果缓存到变量中,是最简单也最有效的优化手段。
for (let i = 0; i < 100; i++) {
document.querySelector('.list').
appendChild(document.createElement('li'));
}
/* ✅ 正确写法:缓存引用 */
const list = document.querySelector('.list');
for (let i = 0; i < 100; i++) {
const li = document.createElement('li');
li.textContent = '第 ' + i + ' 项';
list.appendChild(li);
}
/* 更优:使用 getElementsByClassName 得到"活"集合,
或一次性 querySelectorAll 得到静态 NodeList */
const items = document.querySelectorAll('.item');
// NodeList 是静态的,遍历不会引发重新查询
批量插入:DocumentFragment 与离线 DOM
每调用一次 appendChild,如果元素已在文档中,浏览器就需要重新计算布局。批量插入的正确姿势是先构建一个"离线"结构,再一次性挂载到文档上。
const list = document.querySelector('#list');
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item.name;
list.appendChild(li); // 每次都可能重排
});
/* ✅ 方案一:DocumentFragment */
const fragment = document.createDocumentFragment();
data.forEach(item => {
const li = document.createElement('li');
li.textContent = item.name;
fragment.appendChild(li);
});
list.appendChild(fragment); // 只重排一次
/* ✅ 方案二:先隐藏,操作完再显示 */
list.style.display = 'none';
/* ...大量 DOM 操作... */
list.style.display = 'block';
N 次重排 → 1 次重排
innerHTML、createElement 与 insertAdjacentHTML 的取舍
三种插入方式各有适用场景。理解它们的性能特征与安全边界,才能做出正确选择。
优点:写法简洁;缺点:会销毁原有子节点的事件监听 */
container.innerHTML = '<li>新项</li>';
/* 2. createElement + appendChild:最灵活,最安全 */
const li = document.createElement('li');
li.className = 'item';
li.textContent = userInput; // 天然防 XSS
container.appendChild(li);
/* 3. insertAdjacentHTML:不破坏已有节点 */
container.insertAdjacentHTML('beforeend',
'<li class="item">新项</li>');
/* 4. cloneNode:复用已有结构 */
const template = document.querySelector('#row-template');
const row = template.content.cloneNode(true);
row.querySelector('.name').textContent = item.name;
事件委托:用 1 个监听器管理 1000 个元素
事件委托利用事件冒泡机制,把监听器绑定在父元素上,由父元素统一处理子元素的事件。这不仅能大幅减少监听器数量,还能自动支持动态新增的元素。
document.querySelectorAll('.item').forEach(li => {
li.addEventListener('click', handleClick);
});
/* ✅ 事件委托:只在父容器绑定一次 */
list.addEventListener('click', function (e) {
const target = e.target.closest('.item');
if (!target || !list.contains(target)) return;
// 处理业务逻辑
console.log(target.dataset.id);
});
/* 使用 dataset 传递数据 */
// <li class="item" data-id="42">...</li>
// target.dataset.id === '42'
重排(Reflow)与重绘(Repaint)
重排是浏览器重新计算元素几何属性的过程,代价最高;重绘是重新绘制像素,代价相对较低。优化的核心思路是:减少重排次数、缩小重排范围、避免强制同步布局。
for (let i = 0; i < boxes.length; i++) {
// 读 → 写 → 读 → 写 …… 每次都强制刷新布局
boxes[i].style.height =
container.offsetHeight + 'px';
}
/* ✅ 读写分离:先集中读,再集中写 */
const h = container.offsetHeight; // 只读一次
boxes.forEach(box => {
box.style.height = h + 'px';
});
/* ✅ 脱离文档流后再操作,最后统一回归 */
const clone = container.cloneNode(true);
/* ...在 clone 上做大量修改... */
container.parentNode.replaceChild(clone, container);
/* ✅ 使用 transform / opacity 触发合成层,避免重排 */
box.style.transform = 'translateX(20px)';
box.style.opacity = '0.8';
越往下,性能代价越低
使用 requestAnimationFrame 调度更新
浏览器每秒渲染 60 帧,每帧约 16.67ms。把 DOM 更新放进 requestAnimationFrame,可以让浏览器在下一帧绘制前统一处理,避免布局抖动。
window.addEventListener('scroll', () => {
box.style.transform = `translateY(${window.scrollY}px)`;
});
/* ✅ 用 rAF 合并到每一帧只执行一次 */
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
box.style.transform =
`translateY(${window.scrollY}px)`;
ticking = false;
});
});
/* 通用节流:基于 rAF 的批量更新器 */
function rafBatch(task) {
let queued = false;
return () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => {
task();
queued = false;
});
};
}
虚拟 DOM 与 Diff 思想
虚拟 DOM 的本质并不是"更快",而是"更可控"。它用 JavaScript 对象描述 UI 结构,通过 Diff 算法计算出最小更新集合,再批量应用到真实 DOM 上。
const vnode = {
tag: 'div',
props: { class: 'card', id: 'c1' },
children: [
{ tag: 'h3', props: {}, children: ['标题'] },
{ tag: 'p', props: {}, children: ['内容'] }
]
};
/* 简化的 Diff 思路:同层比较 + key 复用 */
function patch(oldVnode, newVnode) {
if (oldVnode.tag !== newVnode.tag) {
return createElement(newVnode);
}
// 更新属性差异
updateProps(oldVnode, newVnode);
// 递归处理子节点(带 key 的列表可复用)
updateChildren(oldVnode, newVnode);
return oldVnode.el;
}
/* 手写迷你渲染:直接批量替换 */
function render(vnode, container) {
const el = document.createElement(vnode.tag);
Object.entries(vnode.props).forEach(([k, v]) => {
el.setAttribute(k, v);
});
container.appendChild(el);
}
虚拟 DOM 让"什么时候更新、更新什么"变得可计算
现代观察者 API:IntersectionObserver 等
过去我们依赖 scroll、resize 事件配合节流来计算元素位置,代价高昂。现代浏览器提供了更高效的观察者 API,由浏览器在内部以最优方式调度。
const io = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
io.unobserve(img); // 加载后停止观察
}
});
}, { rootMargin: '200px', threshold: 0 });
document.querySelectorAll('img[data-src]')
.forEach(img => io.observe(img));
/* MutationObserver:监听 DOM 变化 */
const mo = new MutationObserver(records => {
records.forEach(r => console.log(r.type, r.target));
});
mo.observe(list, { childList: true, subtree: true });
/* ResizeObserver:监听元素尺寸变化 */
const ro = new ResizeObserver(entries => {
for (const e of entries) {
console.log(e.contentRect.width);
}
});
ro.observe(box);
交给浏览器调度,比手写节流更高效
高效 DOM API 速查
掌握这些现代 API,可以显著减少代码量并提升执行效率,同时避免许多历史遗留的兼容性陷阱。
el.classList.add('active');
el.classList.toggle('open', isOpen);
/* 2. 数据属性:dataset */
el.dataset.userId = '42';
// 对应 <div data-user-id="42">
/* 3. 选择器:closest / matches */
if (e.target.matches('.btn-danger')) { ... }
const card = e.target.closest('.card');
/* 4. 批量操作:批量修改内联样式 */
Object.assign(el.style, {
transform: 'scale(1.05)',
transition: 'transform .2s'
});
/* 或使用 cssText 一次性写入 */
el.style.cssText = 'color:red;font-size:14px;';
/* 5. 模板元素:template + cloneNode */
const tpl = document.querySelector('#row');
const node = tpl.content.cloneNode(true);
/* 6. 现代遍历:children 替代 childNodes */
for (const child of list.children) { ... }
/* 7. 移除节点:remove() 一步到位 */
el.remove(); // 不需要 parentNode.removeChild
类名操作
数据属性
向上查找
选择器匹配
惰性模板
直接移除
用现代 API 替代冗长的传统写法
性能调试与度量
没有度量就没有优化。浏览器提供了一整套性能分析工具,帮助你定位真正的瓶颈,而不是凭感觉猜测。
performance.mark('dom-start');
/* ...大量 DOM 操作... */
performance.mark('dom-end');
performance.measure(
'DOM 操作耗时',
'dom-start',
'dom-end'
);
/* 2. 读取测量结果 */
const entries = performance.getEntriesByName(
'DOM 操作耗时'
);
console.log(entries[0].duration + 'ms');
/* 3. Long Task 观察 */
new PerformanceObserver(list => {
list.getEntries().forEach(entry => {
console.warn('长任务:', entry.duration);
});
}).observe({ entryTypes: ['longtask'] });
/* 4. DevTools 关键面板 */
// Performance:录制并查看 Layout / Recalculate Style
// Rendering → Paint flashing:高亮重绘区域
// Rendering → Layout Shift Regions:查看布局偏移
// Layers:查看合成层划分
高效 DOM 操作最佳实践
- 缓存引用:重复使用的节点只查询一次,存入变量或常量
- 批量更新:使用 DocumentFragment 或离线 DOM 减少重排次数
- 读写分离:先集中读取几何属性,再集中写入样式,避免强制同步布局
- 缩小影响范围:修改样式尽量限制在局部,避免触发全页布局
- 优先使用合成属性:动画优先使用 transform 与 opacity
- 事件委托:用父级一个监听器代替大量子元素监听器
- rAF 调度:把高频更新合并到每一帧执行一次
- 使用观察者 API:IntersectionObserver、ResizeObserver 替代 scroll/resize
- 善用现代 API:classList、dataset、closest、template 让代码更简洁高效
- 持续度量:用 Performance 面板和 performance API 验证优化效果
/* 少查询 → 批量写 → 读写分离 → 交给浏览器调度 */
/* 一个完整的优化示例 */
function renderList(container, data) {
const fragment = document.createDocumentFragment();
const tpl = document.querySelector('#item-tpl');
data.forEach(item => {
const node = tpl.content.cloneNode(true);
node.querySelector('.title').
textContent = item.title;
node.querySelector('.item').
dataset.id = item.id;
fragment.appendChild(node);
});
container.appendChild(fragment); // 一次重排
}