几乎每一个前端工程师的日常,都绕不开 document.querySelector、appendChild、addEventListener 这几个 API。它们简单到几乎不需要学习,随手就能写出能跑的代码——但也正因为太简单,绝大多数项目里都埋着同一批问题:同一个元素被反复查询、循环里逐条插入节点、读写交替触发强制同步布局、事件监听器越绑越多却从不移除、把 DOM 当成数据库用、用 innerHTML 拼接用户输入。这些问题在开发机上跑得好好的,一到低端手机上就掉帧,一上线就被安全扫描报 XSS,一改需求就发现代码里到处是耦合。DOM 操作的“最佳实践”并不是一堆玄学技巧,而是一整套关于“什么时候读、什么时候写、写多少、什么时候释放”的工程纪律。这篇 35 分钟的长文,会从浏览器渲染链路讲起,逐一拆解选择、写入、样式、事件、观察者、内存、无障碍七大主题,并给出可以直接抄进项目的代码基线与检查清单。
一、先搞清楚:DOM 到底是什么,为什么它“慢”
很多文章会把 DOM 说成“JavaScript 的一部分”,这是一个流传极广的误解。事实上,DOM(Document Object Model)是一套与语言无关的编程接口规范,由 W3C 制定、WHATWG 维护。JavaScript 只是众多实现这套接口的语言之一——Python、Java、C# 都能操作 DOM(例如服务端的 jsdom、浏览器扩展的宿主环境)。
在浏览器里,这套接口的宿主是 C++ 实现的渲染引擎。JS 引擎(V8、SpiderMonkey、JavaScriptCore)和渲染引擎是两套独立的对象系统,它们之间通过一层绑定(binding)通信。你在 JS 里写 el.style.width = '100px',背后发生的事情大致是:
- JS 引擎把字符串参数交给绑定层;
- 绑定层做类型检查、转换,调用渲染引擎暴露的 C++ 方法;
- C++ 侧更新内部样式表,并把该元素标记为“样式需要重算”;
- 如果此刻有代码读取了布局属性,浏览器不得不立刻把整个页面重新布局一遍,把最新值算出来再返回。
第 4 步就是所有性能问题的根源。所以“DOM 慢”这个说法其实不准确——单纯的 JS 属性赋值并不慢,慢的是它引发的样式重算、布局重排与绘制。
一次 DOM 写入触发的完整链路
点击下面四个阶段,看看一次看似简单的 DOM 修改,在浏览器内部要走多远。
一张代价表:不同操作的相对开销
下面的对照表给出的是工程经验上的量级关系,不是精确数字。它的价值在于帮你建立直觉:哪些操作是“顺手就能做”,哪些必须“攒起来一起做”。
| 操作 | 触发阶段 | 相对开销 | 建议 |
|---|---|---|---|
el.classList.add() |
重排 + 重绘 | 中 | 批量合并,避免逐条切换 |
el.style.color = ... |
重绘 | 低 | 颜色类属性可放心改 |
el.style.width = ... |
重排 + 重绘 | 高 | 优先用 transform 替代 |
el.textContent = ... |
重排(内容变化时) | 中 | 比 innerHTML 安全且更快 |
parent.appendChild() |
重排 | 中 | 用 Fragment 攒成一次 |
el.offsetWidth(读) |
强制同步布局 | 高 | 集中读取,避免与写交替 |
el.getBoundingClientRect() |
强制同步布局 | 高 | 在 rAF 中统一读取 |
transform / opacity |
仅合成 | 极低 | 动画的首选属性 |
document.createElement() |
无(游离节点) | 极低 | 先在内存里建好再挂载 |
二、选择元素:把查询成本压到最低
选择元素是所有 DOM 操作的第一步,也是被优化得最少的一步。原因很简单:它太快了,快到你感觉不到。但当它在 60fps 的滚动回调、在 input 事件、在 mousemove 里被调用上千次时,累计成本就相当可观了。
API 家族与它们的性格差异
| API | 返回类型 | 是否“活”的 | 性能 | 适用场景 |
|---|---|---|---|---|
getElementById |
Element | null | — | 最快 | 已知 id 的唯一元素 |
getElementsByClassName |
HTMLCollection | 是 | 快 | 需要实时反映 DOM 变化 |
getElementsByTagName |
HTMLCollection | 是 | 快 | 按标签批量处理 |
querySelector |
Element | null | — | 中 | 复杂选择器,取单个 |
querySelectorAll |
NodeList | 否(静态) | 中 | 遍历、缓存,首选 |
closest |
Element | null | — | 快 | 事件委托中向上找祖先 |
matches |
boolean | — | 快 | 条件判断,不修改 DOM |
children |
HTMLCollection | 是 | 极快 | 只想要元素子节点 |
childNodes |
NodeList | 是 | 极快 | 需要包含文本节点 |
第一个大坑:HTMLCollection 是“活”的
这是初学者最容易踩、也最难排查的坑。getElementsByClassName 返回的 HTMLCollection 是一个实时视图:DOM 一变,它立刻跟着变。这意味着如果你一边遍历一边修改,长度会不断变化,循环行为完全失控。
const items = document.getElementsByClassName('item');for (let i = 0; i < items.length; i++) { items[i].remove(); }每次删除后
items 立刻缩短,索引 i 递增导致元素被跳过,最后只删掉一半。
const items = document.querySelectorAll('.item');items.forEach(el => el.remove());querySelectorAll 返回静态 NodeList,遍历过程中不受 DOM 变化影响。
如果确实需要用实时集合来遍历删除,有两个安全的写法:倒序循环(for (let i = items.length - 1; i >= 0; i--)),或者 while 循环(while (items.length) items[0].remove())。但更推荐的做法是统一用 querySelectorAll,让静态快照成为默认习惯。
第二个大坑:在循环里重复查询
for (let i = 0; i < data.length; i++) {
document.querySelector('.list').appendChild(
document.createElement('li')
);
}
// ✅ 容器查询一次,循环内只做必要的创建
const list = document.querySelector('.list');
for (const item of data) {
const li = document.createElement('li');
li.textContent = item.name;
list.appendChild(li);
}
第三个坑:选择器写得过于宽泛或过于具体
- 过于宽泛:
querySelectorAll('div')会匹配页面上所有 div 再逐个过滤,几乎没有意义。 - 过于具体:
body > div.wrap > main > section:nth-child(3) > ul > li a这种选择器一旦结构调整立刻失效,且匹配成本高。 - 推荐做法:给需要操作的元素挂一个语义明确的
data-*属性或稳定的 class,用最短的选择器直达目标。例如[data-role="submit"]。
这里有一个重要的观念转变:CSS 选择器是为样式服务的,DOM 查询选择器可以为“脚本钩子”服务。用 js- 前缀或 data-* 标记脚本专用的钩子,可以让样式重构与脚本逻辑彻底解耦。
<button class="btn btn-primary" data-action="submit">提交</button>
const submitBtn = document.querySelector('[data-action="submit"]');
就近查找:把搜索范围缩到最小
所有查询 API 都有元素级版本。element.querySelector 只在子树内查找,比从 document 开始快得多,也更不容易误伤其他区域。
const title = document.querySelector('.card-title');
// ✅ 从卡片内部查找,范围明确
const card = document.querySelector('[data-card-id="42"]');
const title = card.querySelector('.card-title');
三、写入 DOM:批量、一次、最小化
选择是把东西找出来,写入才是真正让浏览器干活的部分。这一节的核心原则只有一条:把 N 次重排压缩成 1 次。
innerHTML、textContent、createElement 三方对比
| 方式 | 性能 | 安全性 | 可维护性 | 推荐场景 |
|---|---|---|---|---|
innerHTML |
高 | XSS 风险 | 字符串拼接 | 纯静态模板、可信内容 |
textContent |
高 | 安全 | 直观 | 纯文本写入,首选 |
createElement |
中 | 安全 | 清晰 | 需要绑定事件或引用 |
insertAdjacentHTML |
高 | XSS 风险 | 局部插入 | 在指定位置插入片段 |
DocumentFragment |
最高 | 安全 | 清晰 | 批量插入大量节点 |
cloneNode + template |
高 | 安全 | 清晰 | 重复结构渲染 |
注意最后一行里有个笔误式的细节——类的名字必须写对,cost-badge 而不是别的。这类小错误在真实项目里经常导致样式失效,值得顺手检查。
DocumentFragment:一次挂载,一次重排
DocumentFragment 是一个“轻量级的假文档”。你可以在它上面自由地 appendChild,但因为它不在渲染树里,这些操作完全不会触发重排。等你把整个 Fragment 一次性挂到真实 DOM 上时,浏览器只处理一次结构变化。
for (const item of list) {
const li = document.createElement('li');
li.textContent = item.name;
ul.appendChild(li);
}
// ✅ 先在 Fragment 里拼好,再一次性挂载
const frag = document.createDocumentFragment();
for (const item of list) {
const li = document.createElement('li');
li.textContent = item.name;
frag.appendChild(li);
}
ul.appendChild(frag); // 只触发一次
一个容易忽略的细节:Fragment 被挂载后会被“掏空”。它的子节点全部转移到目标容器,Fragment 自身变成空的。所以不要试图复用它,需要就重新创建一个。
replaceChildren:清空并替换的最优解
过去清空一个容器要写 while (el.firstChild) el.removeChild(el.firstChild),或者更粗暴的 el.innerHTML = ''。现代浏览器提供了语义更清晰、性能更好的 API:
list.replaceChildren(...nodes);
// 只清空
list.replaceChildren();
// 对比:innerHTML = '' 会重新解析 HTML,还可能丢失已绑定的引用
insertAdjacentHTML:在指定位置插入
相比 innerHTML +=(它会重新序列化整个容器再重新解析,既慢又会丢失所有事件监听与输入状态),insertAdjacentHTML 只在指定位置插入,不碰已有节点:
list.insertAdjacentHTML('beforeend', '<li>新项目</li>');
// ❌ 这个写法是性能杀手:整个列表被重建
list.innerHTML += '<li>新项目</li>';
+= 都会:序列化整个子树 → 拼接字符串 → 清空容器 → 重新解析 → 重建所有节点。已有的 input 值、焦点、滚动位置、事件监听全部丢失。
insertAdjacentHTML('beforeend', html) 追加,或者用 appendChild / append 挂载新节点。已有节点原封不动。
安全:innerHTML 是 XSS 的头号入口
只要拼接的内容里包含用户输入,innerHTML 就是一个随时可能引爆的炸弹。下面的场景在真实项目里极其常见:
commentEl.innerHTML = '<p>' + user.nickname + '</p>';
// ✅ 纯文本用 textContent,浏览器会自动转义
commentEl.textContent = user.nickname;
// ✅ 需要富文本时,先用 DOMPurify 之类库做净化
commentEl.innerHTML = DOMPurify.sanitize(user.content);
需要特别提醒的是:textContent 也不是万能的安全屏障。当你要把文本插入到属性、URL、CSS 或 <script> 上下文时,转义规则完全不同。最稳妥的原则是——能用 DOM API 构造就用 DOM API 构造,绝不手工拼接 HTML 字符串。
template + cloneNode:重复结构的标准写法
当列表项结构比较复杂(多个标签、多个类名、多个属性)时,用 createElement 一行行搭会很啰嗦。这时 <template> 是更好的选择:模板里的内容在页面加载时不渲染、不执行脚本、不加载图片,直到你克隆它。
<article class="card">
<h3 class="card-name"></h3>
<p class="card-role"></p>
</article>
</template>
const tpl = document.getElementById('user-card');
const frag = document.createDocumentFragment();
for (const user of users) {
const node = tpl.content.cloneNode(true);
node.querySelector('.card-name').textContent = user.name;
node.querySelector('.card-role').textContent = user.role;
frag.appendChild(node);
}
list.appendChild(frag);
注意 tpl.content——访问模板内容必须走 .content 属性,它返回一个 DocumentFragment。直接对 tpl 做 querySelector 是查不到里面内容的,这是非常高频的一个错误。
四、读写分离:躲开强制同步布局
如果这篇长文只能记住一件事,那应该是这一节。它带来的性能差异,往往比前面所有优化加在一起还大。
什么是强制同步布局
正常情况下,浏览器会把一帧内的所有 DOM 修改攒起来,在帧末统一做一次布局(这就是所谓的“批量更新”)。但如果你在修改之后立刻读取某个布局属性,浏览器就没法再等了——它必须马上把布局算出来,才能给你准确的值。这个“被迫提前布局”的行为叫做 强制同步布局(Forced Synchronous Layout),也叫布局抖动(Layout Thrashing)。
关键在于:每一次“写 → 读”的交替,都会触发一次完整的布局重算。在循环里,这就是 O(n) 次布局。
for (const box of boxes) {
box.style.width = box.offsetWidth / 2 + 'px'; // 读一次、写一次
}
// ✅ 先集中读,再集中写:只布局一次
const widths = boxes.map(b => b.offsetWidth);
boxes.forEach((b, i) => {
b.style.width = widths[i] / 2 + 'px';
});
哪些属性读取会触发布局
下面这些属性和方法,只要在 DOM 被修改之后读取,就会强制同步布局。它们都属于“读操作”,应该集中放在一起:
offsetTop / offsetLeft / offsetWidth / offsetHeight
scrollTop / scrollLeft / scrollWidth / scrollHeight
clientTop / clientLeft / clientWidth / clientHeight
getComputedStyle() 读取任何几何相关属性
getBoundingClientRect() / getClientRects()
element.focus() 在某些情况下也会触发滚动与布局
用 requestAnimationFrame 把读写分到两帧
如果读写逻辑确实无法在同一个函数里拆开(比如你需要在动画的每一帧中读取当前滚动位置再决定如何修改),那么标准做法是把“读”放在 requestAnimationFrame 的开头,把“写”放在它的回调末尾,或者用两段 rAF 明确分开:
let metrics;
requestAnimationFrame(() => {
metrics = boxes.map(b => b.getBoundingClientRect());
// 写阶段:放到下一帧,保证不打断当前布局
requestAnimationFrame(() => {
boxes.forEach((b, i) => {
b.style.transform = `translateX(${metrics[i].width}px)`;
});
});
});
用 transform 替代几何属性
还有一个更根本的思路:如果某个视觉效果可以用 transform 或 opacity 实现,就永远不要用 width、height、top、left。前者只走合成阶段,完全不触发布局;后者必然引发重排。
| 想要的效果 | ❌ 会重排的写法 | ✅ 只走合成的写法 |
|---|---|---|
| 元素移动 | left / top / margin |
transform: translate() |
| 放大缩小 | width / height |
transform: scale() |
| 淡入淡出 | visibility / display |
opacity |
| 旋转 | 改 background-position 模拟 |
transform: rotate() |
| 滑动面板 | height: 0 → auto |
transform + clip-path |
window.addEventListener('scroll', () => { const h = el.offsetHeight; // 每帧强制布局 el.style.transform = `translateY(${h}px)`;});滚动事件每秒可以触发上百次,这个写法在低端设备上会直接掉到 20fps 以下。
resize 时读一次并缓存,滚动回调里只做 transform 写入,并用 rAF 做节流。或者干脆改用 IntersectionObserver,从根本上避免滚动监听。
五、样式与类名:把表现层交还给 CSS
DOM 操作里数量最多的,大概就是改样式。而改样式最常见的错误,是逐条写 el.style.xxx。
逐条写 style 的两个代价
- 性能代价:每写一条,浏览器都要检查该属性是否影响布局。虽然同一帧内会被合并,但在读写交替的场景下会放大问题。
- 架构代价:样式逻辑散落在 JS 里,与 CSS 文件中的定义重复、冲突,改一个颜色要翻好几处代码。
el.style.backgroundColor = '#2563eb';el.style.color = '#fff';el.style.borderRadius = '8px';el.style.padding = '8px 16px';一旦设计稿改动,需要在 JS 里全局搜索替换,而且无法被 CSS 变量统一管理。
el.classList.add('btn-primary');样式集中在 CSS 里,主题切换、响应式、深色模式都能自动生效,JS 只负责“切换状态”。
classList 全家桶
el.classList.remove('active');
el.classList.toggle('active');
el.classList.toggle('active', isOn); // 第二个参数强制指定结果
el.classList.contains('active');
el.classList.replace('old', 'new'); // 不存在时返回 false,不报错
// 一次添加多个
el.classList.add('a', 'b', 'c');
// 对比:className 是字符串操作,会整体覆盖
el.className = 'btn active'; // 丢掉了原有的所有类
toggle 的第二个参数非常有用。它让“根据布尔值设置类名”变成一行代码,避免了 if (on) add else remove 的分支:
if (isActive) el.classList.add('active');
else el.classList.remove('active');
// ✅ 一行搞定
el.classList.toggle('active', isActive);
什么时候必须用 style
类名不是万能的。以下三类场景,直接写 style 更合适:
- 连续变化的数值:拖拽时的
translate(x, y)、进度条的width: 37%。为每一个可能的值定义一个类是荒谬的。 - 从运行时数据计算出的值:从接口拿到的主题色、用户自定义字号。
- CSS 自定义属性:这是最优雅的方案,把动态值写进变量,样式规则仍然留在 CSS 里。
.progress-bar {
width: calc(var(--progress, 0) * 1%);
background: var(--bar-color, #2563eb);
}
// JS 只负责注入数值
bar.style.setProperty('--progress', 73);
bar.style.setProperty('--bar-color', '#10b981');
这种写法的好处是:CSS 依然掌握全部表现逻辑,JS 只传递数据。主题切换、动画、媒体查询都能正常参与运算,而且这种属性变更不触发布局(除非变量被用在几何属性上)。
批量设置样式:cssText 与 Object.assign
如果确实需要一次设置多条内联样式,用 cssText 一次性写入,比逐条赋值少几次样式表操作:
el.style.cssText = 'position: absolute; top: 0; left: 0; opacity: 0;';
// 保留已有样式,追加新的
el.style.cssText += '; transform: translateX(20px);';
// 现代写法:Object.assign 批量赋给 style 对象
Object.assign(el.style, {
position: 'absolute',
top: '0',
left: '0',
opacity: '0'
});
六、事件:委托、选项与生命周期
事件是 DOM 操作里最容易积累技术债的地方。一个长期迭代的项目,往往会留下几十个从未被移除的监听器、几处重复绑定的处理函数,以及一个在 scroll 里做重计算的回调。
事件委托:把 N 个监听器变成 1 个
事件冒泡机制让父元素可以“代理”子元素的事件。这在列表、表格、工具栏这类子元素数量多且可能动态增减的场景下,几乎是唯一正确的做法。
点击下面的按钮,对比两种绑定方式:
// 问题:新加的元素没有监听器;元素被移除后监听器仍占用内存
document.querySelectorAll('.toolbar button').forEach(btn => {
btn.addEventListener('click', handleClick);
});
// 100 个按钮就是 100 个监听器对象、100 份闭包
委托的三个关键细节
- 用
closest而不是e.target:用户点到的可能是按钮内部的图标或文字节点,e.target未必是按钮本身。 - 用
contains做边界检查:当容器内部还有嵌套的同类容器时,可以防止事件被重复处理。 - 不是所有事件都冒泡:
focus、blur、mouseenter、mouseleave、load不冒泡。需要委托时,用它们对应的冒泡版本:focusin/focusout。
第三个参数:那些被忽略的选项
addEventListener 的第三个参数从布尔值(是否捕获)演化成了选项对象,里面藏着好几个非常有用的开关:
passive: true, // 承诺不调用 preventDefault,滚动不再等待 JS
once: true, // 触发一次后自动移除
capture: false, // 在冒泡阶段处理
signal: controller.signal // 用 AbortSignal 统一移除
});
passive: true 值得单独强调。浏览器默认无法预知你的 touchstart 或 wheel 回调里会不会调用 preventDefault(),所以必须等 JS 执行完才敢滚动。开启 passive 后,浏览器可以立即滚动,这是移动端滚动流畅度提升最立竿见影的一行代码。
用 AbortController 批量移除监听器
过去移除监听器需要保存函数引用、逐个调用 removeEventListener,还要保证捕获标志一致,非常容易漏。现代浏览器提供了更优雅的方案:
const controller = new AbortController();
const { signal } = controller;
btn.addEventListener('click', onClick, { signal });
input.addEventListener('input', onInput, { signal });
window.addEventListener('resize', onResize, { signal });
// 一行代码移除上面全部监听器
controller.abort();
这个模式特别适合组件化场景:每个组件实例持有一个 controller,卸载时调用一次 abort(),所有监听器、定时器(配合 signal 参数)、fetch 请求都能一并取消。
防止重复绑定
在单页应用里,一个常见的 bug 是同一个元素被多次初始化,导致监听器叠加、回调执行多次。三种防御手段:
dataset.bound = '1' 或 WeakSet 记录已绑定的元素
el.onclick = fn 这类赋值式 API(天然覆盖),仅在需要多监听器时用 addEventListener
七、属性、dataset 与状态管理
attribute 与 property 是两回事
这是 DOM 里最经典的迷惑点之一。attribute 是 HTML 标签上写的值,property 是 DOM 对象上的 JavaScript 属性。它们在页面加载时会同步一次,之后就各走各的。
const input = document.querySelector('input');
// 用户在输入框里打字后:
input.value; // "用户输入的内容"(property 变了)
input.getAttribute('value'); // "初始值"(attribute 没变)
// 同理:checkbox 的 checked 属性
input.checked; // 当前是否勾选(property)
input.getAttribute('checked'); // 初始是否有 checked 属性
实践建议:交互状态一律读写 property(input.value、checkbox.checked、select.selectedIndex);只有需要反映到 HTML 结构上、需要被 CSS 选择器命中的情况,才用 attribute(aria-expanded、hidden、disabled)。
dataset:自定义数据属性的优雅读写
const el = document.querySelector('[data-user-id]');
el.dataset.userId; // "42" —— 连字符转小驼峰
el.dataset.isAdmin; // "true" —— 注意:值永远是字符串
el.dataset.userId = '43'; // 写回 HTML:data-user-id="43"
delete el.dataset.isAdmin; // 移除属性
三个必须记住的点:
- 值永远是字符串。存数字要用
Number(el.dataset.count)转换,存布尔值要判断=== 'true'。 - 转换规则是单向的:
data-user-id→dataset.userId;但反过来的dataset.UserId会变成data--user-id,不是你想要的结果。 - 不要把大数据塞进 dataset。它适合存标识和简单状态,不适合存 JSON 字符串——那会让 DOM 体积膨胀,且在 DevTools 里难以阅读。
不要把 DOM 当数据库
一个非常常见但危害巨大的模式是:把应用状态“存在 DOM 里”,然后靠读 DOM 来获取状态。
const count = Number(counterEl.textContent);counterEl.textContent = count + 1;文本是展示结果,不是数据源。一旦格式化(比如加千分位“1,234”),
Number() 就会返回 NaN,逻辑随之崩溃。
let count = 0;function render() { counterEl.textContent = format(count); }function increment() { count++; render(); }数据是唯一真相来源,DOM 只是它的一个投影。
这条原则的推论是:DOM 更新应该是幂等的。用同一个状态调用两次渲染函数,结果应该完全相同,不应该出现“多加了一次”的情况。做到这一点,调试成本会大幅下降。
toggleAttribute 与其他属性小工具
el.toggleAttribute('disabled', shouldDisable);
el.toggleAttribute('hidden', isHidden);
// 现代节点操作四件套
el.before(node); // 在 el 前面插入
el.after(node); // 在 el 后面插入
el.replaceWith(node); // 替换 el 自身
el.remove(); // 从 DOM 中摘除(无需父节点)
八、观察者 API:从轮询到通知
过去的 DOM 编程里,很多需求只能靠“监听一个高频事件 + 每帧计算”来实现:判断元素是否进入视口靠 scroll、判断尺寸变化靠 resize、判断节点被插入靠轮询。这些方案既费性能又不准确。现代浏览器提供了三个观察者 API,把这些问题全部变成事件驱动的通知。
IntersectionObserver:可见性检测的标准方案
图片懒加载、无限滚动、曝光埋点、滚动动画触发——这些需求的共同点是“判断元素是否进入视口”。用 scroll 事件实现需要频繁调用 getBoundingClientRect()(每次都强制布局),而 IntersectionObserver 把这项工作交给了浏览器内部,主线程完全不受影响。
for (const entry of entries) {
if (!entry.isIntersecting) continue;
// 元素进入视口:加载真实图片
const img = entry.target;
img.src = img.dataset.src;
// 一次性任务,完成后立即停止观察
observer.unobserve(img);
}
}, {
root: null, // 默认视口,也可指定滚动容器
rootMargin: '200px', // 提前 200px 触发,让加载更从容
threshold: 0.1 // 露出 10% 即视为可见
});
document.querySelectorAll('img[data-src]').forEach(img => {
observer.observe(img);
});
三个配置项的作用值得记住:root 指定参考容器(做“容器内滚动”时必填)、rootMargin 扩展或收缩判定边界(做预加载的利器)、threshold 指定触发比例(可以是数组,如 [0, 0.5, 1],用于分段触发)。
MutationObserver:监听 DOM 结构变化
当你需要知道“某个节点被添加/删除了”“某个属性被改了”,MutationObserver 是唯一可靠的方案。它比已经废弃的 DOMNodeInserted 等变更事件性能好得多,因为回调是异步批量触发的,一次可以携带多条变更记录。
for (const record of records) {
if (record.type === 'childList') {
record.addedNodes.forEach(n => console.log('新增', n));
record.removedNodes.forEach(n => console.log('移除', n));
}
if (record.type === 'attributes') {
console.log('属性变化', record.attributeName);
}
}
});
mo.observe(container, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'data-state']
});
典型用途包括:给第三方脚本注入的节点补样式、监控内容区变化以重新计算布局、在无框架的老项目里做简单的响应式联动。但不要用它来做数据绑定——用观察者去响应自己刚做过的修改,很容易写出无限循环。
ResizeObserver:元素级尺寸监听
window.resize 只能告诉你窗口变了,无法告诉你某个具体元素变了。而很多响应式逻辑恰恰是元素级的——比如容器宽度变化时切换布局、图表重绘、文本截断判断。
for (const entry of entries) {
const { width, height } = entry.contentRect;
chart.resize(width, height);
}
});
ro.observe(chartContainer);
注意:ResizeObserver 的回调在布局之后、绘制之前执行。如果在回调里又修改了被观察元素的尺寸,会触发新一轮回调——这就是臭名昭著的“ResizeObserver loop limit exceeded”错误的来源。回调里不要直接改被观察元素的尺寸,要么改内部子元素,要么放到 rAF 里异步处理。
九、内存与生命周期:那些看不见的泄漏
DOM 相关的内存泄漏是最隐蔽的一类问题。页面不报错、功能正常,只是越用越卡,直到某次操作后突然崩溃。原因通常只有一句话:已经不需要的对象,仍然被某个可达的引用链牵着。
泄漏一:被移除的节点仍被 JS 引用
const cachedNodes = [];
function addItem(el) {
cachedNodes.push(el);
}
function clearList() {
list.innerHTML = ''; // DOM 里的节点没了,但 cachedNodes 还指着它们
}
// ✅ 同步清理引用
function clearList() {
list.replaceChildren();
cachedNodes.length = 0;
}
游离的 DOM 节点(不在文档树中但被 JS 引用)占用的内存往往比想象中大得多:它保留着整棵子树、所有的属性、样式对象,以及挂在上面的监听器闭包。
泄漏二:监听器与闭包
当一个元素被移除时,它身上的监听器不会自动消失。如果监听器是一个闭包,捕获了外部的大对象(比如整个组件实例、一份数据数组),这些对象都会跟着一起泄漏。
function mount() {
const bigData = fetchHugeDataset();
window.addEventListener('scroll', () => {
console.log(bigData.length);
});
}
// ✅ 用 signal 显式控制生命周期
function mount() {
const controller = new AbortController();
const bigData = fetchHugeDataset();
window.addEventListener('scroll', () => {
console.log(bigData.length);
}, { signal: controller.signal });
return () => controller.abort();
}
泄漏三:定时器与 rAF 循环
setInterval 和递归的 requestAnimationFrame 如果不显式停止,会一直跑下去。在单页应用里切换路由后,旧页面的定时器仍在后台执行,既浪费 CPU 又阻止内存回收。
const timers = [];
let rafId = null;
function destroy() {
timers.forEach(clearInterval);
timers.length = 0;
if (rafId !== null) cancelAnimationFrame(rafId);
controller.abort();
observer.disconnect();
}
WeakMap 与 WeakSet:不阻止回收的引用
当你需要给 DOM 元素附加额外数据,又不希望这个引用阻止元素被回收时,WeakMap 是标准答案:
const meta = new Map();
meta.set(el, { clicks: 0 });
// ✅ WeakMap:键是弱引用,元素被回收时条目自动消失
const meta = new WeakMap();
meta.set(el, { clicks: 0 });
meta.get(el).clicks++;
// 用 WeakSet 记录“已初始化”,避免重复绑定
const initialized = new WeakSet();
if (!initialized.has(el)) {
init(el);
initialized.add(el);
}
如何发现内存泄漏
- 打开 DevTools 的 Memory 面板,选择 Heap snapshot。
- 在页面上执行一次操作(例如打开再关闭一个弹窗),拍一张快照。
- 再执行 5~10 次同样的操作,再拍一张快照。
- 在第二张快照中选择 Comparison 视图,按 Delta 排序。
- 重点关注
Detached HTMLElement、Detached HTMLDivElement这类条目——它们就是游离的 DOM 节点,数量应该保持稳定,持续增长就说明有泄漏。
还有一个更直观的工具:Performance monitor(DevTools 右上角菜单 → More tools)。勾选 DOM Nodes 和 JS heap size,然后反复操作页面,观察曲线是否持续上升而不回落。
十、焦点、无障碍与 DOM 更新
动态更新 DOM 时,最容易破坏的就是键盘与屏幕阅读器用户的体验。因为他们依赖的是焦点位置和可访问性树,而这些在你用 JS 替换节点时会悄悄改变。
问题一:更新内容后焦点丢失
最常见的场景是列表刷新。如果你用 innerHTML 重建了整个列表,原本聚焦在某个按钮上的焦点会退回 body,键盘用户瞬间“迷路”——按 Tab 会从页面开头重新开始。
list.innerHTML = newItems.map(renderItem).join('');全部节点被替换,焦点、选中状态、滚动位置全部丢失。
item.querySelector('.name').textContent = ...。节点保持存活,焦点自然保留。
问题二:模态框与焦点管理
打开模态框时,焦点应该移入框内;关闭时,焦点应该归还给触发它的元素。自己实现很容易遗漏,而原生的 <dialog> 把这些行为全部内置了:
<h2>确认删除?</h2>
<form method="dialog">
<button value="cancel">取消</button>
<button value="ok">删除</button>
</form>
</dialog>
// showModal 自动:移入焦点、锁定背景、Esc 可关闭、关闭后归还焦点
dialog.showModal();
问题三:异步更新需要被“播报”
屏幕阅读器默认只朗读用户主动操作引发的区域变化。如果你通过 AJAX 更新了页面上的某个提示条、购物车数量、搜索结果数量,用户是听不到的。这时需要 aria-live 区域:
<p role="status" aria-live="polite" id="cart-status"></p>
// 更新内容会被自动播报
cartStatus.textContent = `已添加 1 件商品,购物车共 ${total} 件`;
注意:aria-live 区域必须一开始就存在于 DOM 中,不能动态创建后再填内容——那样屏幕阅读器不会播报。正确做法是页面上放一个空的容器,之后只更新它的文本。
问题四:加载状态与骨架屏
异步内容加载时,如果什么都不显示,用户无法判断页面是卡住了还是在加载。同时,对于屏幕阅读器用户,视觉上的加载动画是完全不可感知的。推荐的做法是:
<!-- 骨架屏:视觉占位 -->
<div class="skeleton-card">
<div class="skeleton-thumb"></div>
<div class="skeleton-lines">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
</div>
</div>
// 加载完成后切换状态
list.removeAttribute('aria-busy');
list.replaceChildren(...nodes);
问题五:批量插入的逐条播报
当你一次性插入 20 个列表项到 aria-live 区域时,屏幕阅读器会逐条朗读,用户可以听上几分钟。解决办法是先移除 live 属性,插入完成后再加回来,并只播报一句总结:
results.replaceChildren(...nodes);
results.setAttribute('aria-live', 'polite');
// 用单独的状态区域播报总结
status.textContent = `共找到 ${nodes.length} 条结果`;
十一、现代 DOM API 速查
过去十年,DOM 标准补充了大量实用 API,很多需求不再需要手写工具函数或引入工具库。这一节把它们集中列出来,方便按需查阅。
节点操作
el.prepend(node); // 插到最前
el.before(node); // 插到 el 之前
el.after(node); // 插到 el 之后
el.replaceWith(node); // 用 node 替换 el
el.replaceChildren(...nodes); // 清空并填充
el.remove(); // 自我移除
el.cloneNode(true); // 深拷贝
遍历与查找
el.matches(':disabled'); // 判断是否匹配选择器
el.children; // 元素子节点(不含文本节点)
el.firstElementChild; // 第一个元素子节点
el.nextElementSibling; // 下一个兄弟元素
el.parentElement; // 父元素
滚动
el.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
});
// 容器内滚动
container.scrollTo({ top: 0, behavior: 'smooth' });
// 保留滚动位置:更新内容前记录,更新后恢复
const y = container.scrollTop;
updateList();
container.scrollTop = y;
动画
el.animate([
{ transform: 'translateX(0)', opacity: 1 },
{ transform: 'translateX(100px)', opacity: 0 }
], {
duration: 300,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
fill: 'forwards'
});
Popover:原生浮层
<div id="menu" popover>菜单内容</div>
// 纯 HTML 即可实现:自动定位、点击外部关闭、Esc 关闭、顶层渲染
// JS 控制:el.showPopover() / el.hidePopover() / el.togglePopover()
Custom Elements:组件化的原生方案
connectedCallback() {
// 元素被插入文档时调用:这里绑事件、拉数据
this.controller = new AbortController();
this.addEventListener('click', this.onClick, {
signal: this.controller.signal
});
}
disconnectedCallback() {
// 元素被移除时调用:这里做清理,防止内存泄漏
this.controller.abort();
}
}
customElements.define('user-card', UserCard);
connectedCallback / disconnectedCallback 这一对钩子,是原生组件最重要的生命周期。所有在 connectedCallback 中创建的东西——监听器、定时器、观察者、网络请求——都应该在 disconnectedCallback 中销毁。这是避免内存泄漏最有效的结构性约束。
十二、八个最常见的 DOM 操作误区
appendChild,而不是用 DocumentFragment 攒批
innerHTML += 追加内容,导致整个容器被重建、状态全丢
offsetWidth,在循环里造成布局抖动
getElementsByClassName 边遍历边删除,被“活集合”坑到
innerHTML,埋下 XSS 隐患
需要展开说的两个点
关于“性能优化”的边界:以上所有建议都有适用范围。一个只有 5 个列表项的页面,用不用 Fragment 完全没有区别;一个静态的营销页,纠结布局抖动是浪费时间。优化的第一步永远是测量——用 Performance 面板录制一段真实操作,看火焰图里到底哪里在耗时。基于直觉的优化,往往是给自己增加复杂度却没有收益。
关于“框架帮我做好了”:React、Vue 这类框架确实封装了批量更新、虚拟 DOM 比对、事件委托(React 17+ 把事件统一挂到根容器)。但框架无法帮你解决所有问题:直接操作 DOM 的第三方库(图表、地图、富文本编辑器)、ref 里手写的命令式代码、框架之外的原生脚本,依然需要你自己遵守这些原则。更重要的是,理解底层机制,才能知道框架在什么情况下会“帮倒忙”——比如为什么在 useLayoutEffect 里读布局是安全的,而在 useEffect 里可能造成闪烁。
十三、重构实战:把一段“能跑”的代码改造成“能维护”
下面是一个典型的列表渲染函数。它在小数据量下完全正常,但把前面提到的坑踩了个遍。点击按钮对比改造前后的写法。
// 1. 每次都重新查询容器
document.querySelector('.list').innerHTML = '';
for (let i = 0; i < data.length; i++) {
// 2. 用字符串拼 HTML,用户数据未转义
const html = '<li class="item">' + data[i].name + '</li>';
// 3. 逐条插入,每条都触发一次结构变更
document.querySelector('.list').insertAdjacentHTML('beforeend', html);
// 4. 写入之后立刻读取,强制同步布局
const h = document.querySelector('.list').offsetHeight;
document.querySelector('.list').style.height = h + 'px';
// 5. 逐个绑定事件,元素重建后监听器全部丢失
document.querySelectorAll('.item')[i].addEventListener('click', () => {
console.log(data[i].name);
});
}
}
改造带来的具体收益
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 重排次数(n 条数据) | n 次以上 | 1 次 |
| 选择器查询次数 | 3n + 1 次 | 1 次 |
| 事件监听器数量 | n 个(且会丢失) | 1 个(永久有效) |
| XSS 风险 | 存在 | 消除 |
| 滚动位置保持 | 丢失 | 保持 |
| 输入框状态保持 | 丢失 | 保持 |
| 可读性 | 命令式混杂 | 读/写分离 |
这个对比最有价值的地方在于:改造后的代码并没有变长多少,也没有引入任何抽象层或工具库。它只是把操作顺序重新组织了一遍,把该缓存的缓存了、该合并的合并了、该委托的委托了。这才是“最佳实践”这四个字的真实含义——它不是额外的负担,而是更自然的写法。
十四、DOM 操作检查清单与代码基线
把全文结论浓缩成一份可以贴在工位上的清单。Code Review 时逐条扫一眼,能挡掉绝大多数问题。
- 容器查询只做一次,缓存在模块级或组件实例上,不在循环里重复查询。
- 批量插入用 DocumentFragment,或者用
replaceChildren一次替换。 - 绝不用
innerHTML +=追加,需要追加时用insertAdjacentHTML('beforeend', …)或 DOM API。 - 用户输入一律用
textContent,需要富文本必须经过净化库处理。 - 读写分离:先把所有布局相关的值集中读出来,再统一写入。
- 动画优先用
transform与opacity,避免修改 width / height / top / left。 - 样式切换优先用
classList,动态数值用 CSS 自定义属性传递。 - 列表类界面一律用事件委托,监听器挂在稳定的父容器上。
- 滚动、触摸类监听器加
{ passive: true }。 - 用
AbortController统一管理监听器生命周期,组件卸载时一次abort()。 - 定时器与 rAF 循环在卸载时显式清除,不留后台任务。
- 用
WeakMap/WeakSet存元素元数据,避免阻止垃圾回收。 - 可见性检测用
IntersectionObserver,不用 scroll + getBoundingClientRect。 - 元素尺寸监听用
ResizeObserver,不用 window.resize 轮询。 - 动态内容区域加
aria-live,让屏幕阅读器能播报变化。 - 批量更新前先关闭
aria-live,更新完只播报一句总结。 - 模态框优先用原生
<dialog>,焦点管理交给浏览器。 - 更新列表尽量原地修改,保住焦点、选中状态与滚动位置。
- 状态存在 JS 变量里,DOM 只是它的渲染结果,渲染函数保持幂等。
- 用 Performance 面板做验证,不凭直觉优化。
// 1. 模块级缓存 + 生命周期控制
const controller = new AbortController();
const { signal } = controller;
// 2. 一次性查询并缓存容器
const listEl = document.querySelector('[data-role="list"]');
// 3. 事件委托,只绑一次
listEl.addEventListener('click', (e) => {
const item = e.target.closest('[data-id]');
if (!item || !listEl.contains(item)) return;
handleSelect(item.dataset.id);
}, { signal });
// 4. 渲染:先读后写,批量挂载
function render(rows) {
const prevScroll = listEl.scrollTop;
const frag = document.createDocumentFragment();
for (const row of rows) {
const li = document.createElement('li');
li.dataset.id = row.id;
li.textContent = row.title;
frag.appendChild(li);
}
listEl.replaceChildren(frag);
listEl.scrollTop = prevScroll;
}
// 5. 清理:一处销毁,全部释放
function destroy() {
controller.abort();
observer.disconnect();
}
DOM 操作最有趣的地方在于它的“时间差”:性能问题不会在开发机上暴露,安全问题不会在功能测试中暴露,内存问题不会在单次操作中暴露。它们都在用户量、数据量、使用时长积累到一定程度后才浮出水面,而那时改造成本已经非常高。前面这些原则的价值,恰恰在于让这些问题压根不会出现。
最后留一句话给每一个正在敲键盘的你:你写的每一行 DOM 操作,最终都会跑在某个人的旧手机、某个人的读屏软件、某个人的低带宽网络里。写下 appendChild 之前多想半秒,也许就省掉了别人一次崩溃的加载。