DOM操作最佳实践

张玥 2026年9月20日 阅读时间 35分钟
DOM JavaScript 性能优化 事件委托 内存管理 最佳实践
前端开发技巧之DOM操作最佳实践

几乎每一个前端工程师的日常,都绕不开 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',背后发生的事情大致是:

  1. JS 引擎把字符串参数交给绑定层;
  2. 绑定层做类型检查、转换,调用渲染引擎暴露的 C++ 方法;
  3. C++ 侧更新内部样式表,并把该元素标记为“样式需要重算”;
  4. 如果此刻有代码读取了布局属性,浏览器不得不立刻把整个页面重新布局一遍,把最新值算出来再返回。

第 4 步就是所有性能问题的根源。所以“DOM 慢”这个说法其实不准确——单纯的 JS 属性赋值并不慢,慢的是它引发的样式重算、布局重排与绘制。

一次 DOM 写入触发的完整链路

点击下面四个阶段,看看一次看似简单的 DOM 修改,在浏览器内部要走多远。

1
查询
Query
遍历 DOM 树匹配选择器。元素越多、选择器越复杂、层级越深,这一步越贵。把结果缓存下来,是成本最低的一档优化。
2
修改
Mutate
修改属性、类名、文本或子树结构。浏览器会把这些改动标记为脏(dirty),并尽量推迟到下一帧统一处理。
3
重排
Reflow / Layout
重新计算每个元素的几何位置与尺寸。整棵树的布局都可能被牵连,这是成本最高的一环。改动几何属性(width、height、margin、font-size)必然触发。
4
重绘与合成
Paint / Composite
把结果画到图层上,再合成到屏幕。只改颜色、背景、阴影这类不影响布局的属性,可以跳过重排,只走重绘甚至只走合成。

一张代价表:不同操作的相对开销

下面的对照表给出的是工程经验上的量级关系,不是精确数字。它的价值在于帮你建立直觉:哪些操作是“顺手就能做”,哪些必须“攒起来一起做”。

操作 触发阶段 相对开销 建议
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 操作慢,所以要少操作 DOM。”——这句话本身没错,但它容易让人走向另一个极端:为了“少操作”而写出难以维护的巨型一次性赋值。正确的说法是:把许多次小操作合并成少数几次大操作,而不是减少功能。
应该建立的心智模型
DOM 写入是“攒批”的:浏览器会把同一任务(task)内的多次修改合并,在一帧结束时统一重排。你要做的,是不要在中间插入读取操作,否则攒批会被强行打断。

二、选择元素:把查询成本压到最低

选择元素是所有 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,让静态快照成为默认习惯。

第二个大坑:在循环里重复查询

// ❌ 每次迭代都重新查询同一个容器,1000 次就是 1000 次选择器匹配
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 上时,浏览器只处理一次结构变化。

// ❌ 循环里逐条插入:1000 个 li 触发 1000 次结构变更
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 只在指定位置插入,不碰已有节点:

// 四个位置:beforebegin / afterbegin / beforeend / afterend
list.insertAdjacentHTML('beforeend', '<li>新项目</li>');

// ❌ 这个写法是性能杀手:整个列表被重建
list.innerHTML += '<li>新项目</li>';
innerHTML += 的连锁反应
每次 += 都会:序列化整个子树 → 拼接字符串 → 清空容器 → 重新解析 → 重建所有节点。已有的 input 值、焦点、滚动位置、事件监听全部丢失。
正确的追加方式
用 insertAdjacentHTML('beforeend', html) 追加,或者用 appendChild / append 挂载新节点。已有节点原封不动。

安全:innerHTML 是 XSS 的头号入口

只要拼接的内容里包含用户输入,innerHTML 就是一个随时可能引爆的炸弹。下面的场景在真实项目里极其常见:

// ❌ 用户昵称里只要有 <img src=x onerror=...> 就会执行
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> 是更好的选择:模板里的内容在页面加载时不渲染、不执行脚本、不加载图片,直到你克隆它。

<template id="user-card">
  <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 以下。
正确姿势:缓存 + rAF 节流
尺寸在 resize 时读一次并缓存,滚动回调里只做 transform 写入,并用 rAF 做节流。或者干脆改用 IntersectionObserver,从根本上避免滚动监听。

五、样式与类名:把表现层交还给 CSS

DOM 操作里数量最多的,大概就是改样式。而改样式最常见的错误,是逐条写 el.style.xxx。

逐条写 style 的两个代价

  1. 性能代价:每写一条,浏览器都要检查该属性是否影响布局。虽然同一帧内会被合并,但在读写交替的场景下会放大问题。
  2. 架构代价:样式逻辑散落在 JS 里,与 CSS 文件中的定义重复、冲突,改一个颜色要翻好几处代码。
反例:JS 里硬编码样式
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.add('active');
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 里。
/* CSS 里定义规则,值由 JS 提供 */
.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 的第三个参数从布尔值(是否捕获)演化成了选项对象,里面藏着好几个非常有用的开关:

el.addEventListener('scroll', onScroll, {
  passive: true, // 承诺不调用 preventDefault,滚动不再等待 JS
  once: true, // 触发一次后自动移除
  capture: false, // 在冒泡阶段处理
  signal: controller.signal // 用 AbortSignal 统一移除
});

passive: true 值得单独强调。浏览器默认无法预知你的 touchstart 或 wheel 回调里会不会调用 preventDefault(),所以必须等 JS 执行完才敢滚动。开启 passive 后,浏览器可以立即滚动,这是移动端滚动流畅度提升最立竿见影的一行代码。

用 AbortController 批量移除监听器

过去移除监听器需要保存函数引用、逐个调用 removeEventListener,还要保证捕获标志一致,非常容易漏。现代浏览器提供了更优雅的方案:

// 一个 controller 管一组监听器
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 记录已绑定的元素
幂等 API 优先使用 el.onclick = fn 这类赋值式 API(天然覆盖),仅在需要多监听器时用 addEventListener
生命周期 把绑定写在挂载钩子里,把解绑写在卸载钩子里,成对出现
重构 用事件委托替代逐元素绑定,从根上避免重复

七、属性、dataset 与状态管理

attribute 与 property 是两回事

这是 DOM 里最经典的迷惑点之一。attribute 是 HTML 标签上写的值,property 是 DOM 对象上的 JavaScript 属性。它们在页面加载时会同步一次,之后就各走各的。

<input value="初始值">

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:自定义数据属性的优雅读写

<div data-user-id="42" data-is-admin="true"></div>

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 来获取状态。

反模式: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 与其他属性小工具

// 布尔属性的开关:不用再写 if/else
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 把这项工作交给了浏览器内部,主线程完全不受影响。

const observer = new IntersectionObserver((entries) => {
  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 等变更事件性能好得多,因为回调是异步批量触发的,一次可以携带多条变更记录。

const mo = new MutationObserver((records) => {
  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 只能告诉你窗口变了,无法告诉你某个具体元素变了。而很多响应式逻辑恰恰是元素级的——比如容器宽度变化时切换布局、图表重绘、文本截断判断。

const ro = new ResizeObserver((entries) => {
  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 引用

// ❌ 从 DOM 移除,但 cachedNodes 仍持有引用
const cachedNodes = [];
function addItem(el) {
  cachedNodes.push(el);
}

function clearList() {
  list.innerHTML = ''; // DOM 里的节点没了,但 cachedNodes 还指着它们
}

// ✅ 同步清理引用
function clearList() {
  list.replaceChildren();
  cachedNodes.length = 0;
}

游离的 DOM 节点(不在文档树中但被 JS 引用)占用的内存往往比想象中大得多:它保留着整棵子树、所有的属性、样式对象,以及挂在上面的监听器闭包。

泄漏二:监听器与闭包

当一个元素被移除时,它身上的监听器不会自动消失。如果监听器是一个闭包,捕获了外部的大对象(比如整个组件实例、一份数据数组),这些对象都会跟着一起泄漏。

// ❌ 组件卸载后,闭包捕获的 bigData 依然无法回收
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 是标准答案:

// ❌ 用普通 Map 存元数据:元素被移除后依然被 Map 引用
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);
}

如何发现内存泄漏

  1. 打开 DevTools 的 Memory 面板,选择 Heap snapshot。
  2. 在页面上执行一次操作(例如打开再关闭一个弹窗),拍一张快照。
  3. 再执行 5~10 次同样的操作,再拍一张快照。
  4. 在第二张快照中选择 Comparison 视图,按 Delta 排序。
  5. 重点关注 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> 把这些行为全部内置了:

<dialog id="confirm">
  <h2>确认删除?</h2>
  <form method="dialog">
    <button value="cancel">取消</button>
    <button value="ok">删除</button>
  </form>
</dialog>

// showModal 自动:移入焦点、锁定背景、Esc 可关闭、关闭后归还焦点
dialog.showModal();

问题三:异步更新需要被“播报”

屏幕阅读器默认只朗读用户主动操作引发的区域变化。如果你通过 AJAX 更新了页面上的某个提示条、购物车数量、搜索结果数量,用户是听不到的。这时需要 aria-live 区域:

<!-- polite:等用户空闲时朗读;assertive:立即打断朗读 -->
<p role="status" aria-live="polite" id="cart-status"></p>

// 更新内容会被自动播报
cartStatus.textContent = `已添加 1 件商品,购物车共 ${total} 件`;

注意:aria-live 区域必须一开始就存在于 DOM 中,不能动态创建后再填内容——那样屏幕阅读器不会播报。正确做法是页面上放一个空的容器,之后只更新它的文本。

问题四:加载状态与骨架屏

异步内容加载时,如果什么都不显示,用户无法判断页面是卡住了还是在加载。同时,对于屏幕阅读器用户,视觉上的加载动画是完全不可感知的。推荐的做法是:

<div class="list" aria-busy="true">
  <!-- 骨架屏:视觉占位 -->
  <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.setAttribute('aria-live', 'off');
results.replaceChildren(...nodes);
results.setAttribute('aria-live', 'polite');

// 用单独的状态区域播报总结
status.textContent = `共找到 ${nodes.length} 条结果`;

十一、现代 DOM API 速查

过去十年,DOM 标准补充了大量实用 API,很多需求不再需要手写工具函数或引入工具库。这一节把它们集中列出来,方便按需查阅。

节点操作

el.append(node, '文本'); // 可同时插入节点和字符串
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.closest('.card'); // 向上找最近的匹配祖先
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;

动画

// Web Animations API:JS 控制的关键帧动画
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:原生浮层

<button popovertarget="menu">打开菜单</button>
<div id="menu" popover>菜单内容</div>

// 纯 HTML 即可实现:自动定位、点击外部关闭、Esc 关闭、顶层渲染
// JS 控制:el.showPopover() / el.hidePopover() / el.togglePopover()

Custom Elements:组件化的原生方案

class UserCard extends HTMLElement {
  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 操作误区

误区 1 在循环里逐条 appendChild,而不是用 DocumentFragment 攒批
误区 2 用 innerHTML += 追加内容,导致整个容器被重建、状态全丢
误区 3 修改样式后立刻读取 offsetWidth,在循环里造成布局抖动
误区 4 用 getElementsByClassName 边遍历边删除,被“活集合”坑到
误区 5 给每个列表项单独绑事件,而不是在父容器上做事件委托
误区 6 把用户输入拼进 innerHTML,埋下 XSS 隐患
误区 7 组件卸载时不移除监听器、不清定时器,内存持续增长
误区 8 把 DOM 文本内容当作数据源,靠读 DOM 来推导状态

需要展开说的两个点

关于“性能优化”的边界:以上所有建议都有适用范围。一个只有 5 个列表项的页面,用不用 Fragment 完全没有区别;一个静态的营销页,纠结布局抖动是浪费时间。优化的第一步永远是测量——用 Performance 面板录制一段真实操作,看火焰图里到底哪里在耗时。基于直觉的优化,往往是给自己增加复杂度却没有收益。

关于“框架帮我做好了”:React、Vue 这类框架确实封装了批量更新、虚拟 DOM 比对、事件委托(React 17+ 把事件统一挂到根容器)。但框架无法帮你解决所有问题:直接操作 DOM 的第三方库(图表、地图、富文本编辑器)、ref 里手写的命令式代码、框架之外的原生脚本,依然需要你自己遵守这些原则。更重要的是,理解底层机制,才能知道框架在什么情况下会“帮倒忙”——比如为什么在 useLayoutEffect 里读布局是安全的,而在 useEffect 里可能造成闪烁。

十三、重构实战:把一段“能跑”的代码改造成“能维护”

下面是一个典型的列表渲染函数。它在小数据量下完全正常,但把前面提到的坑踩了个遍。点击按钮对比改造前后的写法。

function renderList(data) {
  // 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 面板做验证,不凭直觉优化。
/* ============ 一份可直接复用的 DOM 操作代码基线 ============ */

// 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 之前多想半秒,也许就省掉了别人一次崩溃的加载。