DOM API 与事件系统

深入理解 DOM 操作与事件系统的底层机制,掌握事件冒泡、捕获、委托等核心模式,构建高性能的交互层。

DOM API 与事件系统

一、为什么要深入理解 DOM API

1.1 框架背后的真相

无论你使用 React、Vue 还是 Svelte,这些框架最终都要操作 DOM。React 的虚拟 DOM Diff 算法产出的是 DOM 操作指令,Vue 的响应式系统最终触发的也是 DOM 更新。理解原生 DOM API 意味着你能:

  • 理解框架在"幕后"做了什么
  • 在框架无法覆盖的场景下直接操作 DOM(如复杂动画、第三方库集成)
  • 写出更高性能的代码——因为你知道哪些操作代价高昂

1.2 DOM 的本质

DOM(Document Object Model)是浏览器将 HTML 文档解析后生成的树形对象模型。每个 HTML 元素对应一个 DOM 节点(Node),节点之间通过父子、兄弟关系连接。

Document
 └─ html (Element)
     ├─ head (Element)
     │   └─ title (Element)
     │       └─ "页面标题" (Text)
     └─ body (Element)
         ├─ h1 (Element)
         │   └─ "Hello" (Text)
         └─ p (Element)
             └─ "World" (Text)

关键认知:DOM 不是 HTML,也不是 JavaScript。DOM 是一个与语言无关的接口规范,由浏览器实现,JavaScript 通过这个接口操作文档。


二、节点查询与遍历

2.1 现代查询 API

// 推荐:CSS 选择器查询
const el = document.querySelector('.card:first-child');
const els = document.querySelectorAll('[data-type="active"]');

// 返回值类型差异
// querySelector → Element | null
// querySelectorAll → NodeList(静态快照,非实时)

// 遗留 API(仍有使用场景)
document.getElementById('app');           // 最快——直接哈希查找
document.getElementsByClassName('card');  // HTMLCollection(实时更新)
document.getElementsByTagName('div');     // HTMLCollection(实时更新)

2.2 NodeList vs HTMLCollection

特性 NodeList(querySelectorAll) HTMLCollection
实时性 静态(查询时的快照) 实时(DOM 变化会反映)
可遍历 forEachfor...of for 循环(无 forEach
包含内容 所有节点类型 仅 Element 节点
// HTMLCollection 的实时陷阱
const divs = document.getElementsByTagName('div');
console.log(divs.length); // 5

document.body.appendChild(document.createElement('div'));
console.log(divs.length); // 6 —— 自动更新!

// 安全做法:转换为数组
const divsArray = [...document.getElementsByTagName('div')];

2.3 DOM 遍历

const el = document.querySelector('.target');

// 父节点
el.parentElement;
el.closest('.ancestor'); // 向上查找匹配选择器的最近祖先

// 子节点
el.children;           // HTMLCollection,仅 Element
el.childNodes;         // NodeList,包含 Text、Comment 等
el.firstElementChild;
el.lastElementChild;

// 兄弟节点
el.previousElementSibling;
el.nextElementSibling;

三、DOM 操作

3.1 创建与插入

// 创建元素
const div = document.createElement('div');
div.className = 'card';
div.textContent = '新卡片';

// 插入方式对比
parent.appendChild(child);           // 末尾插入
parent.insertBefore(newEl, refEl);   // 在参考节点前插入
parent.prepend(child);               // 开头插入(可接受字符串)
parent.append(child1, child2);       // 末尾插入(可多个)

// 现代插入 API(更灵活)
el.before(newEl);        // 元素前面
el.after(newEl);         // 元素后面
el.replaceWith(newEl);   // 替换

// insertAdjacentHTML — 高性能 HTML 插入
el.insertAdjacentHTML('beforebegin', '<div>前面</div>');
el.insertAdjacentHTML('afterbegin', '<div>开头</div>');
el.insertAdjacentHTML('beforeend', '<div>末尾</div>');
el.insertAdjacentHTML('afterend', '<div>后面</div>');

3.2 批量操作与性能

DocumentFragment 是批量 DOM 操作的关键优化手段:

// ❌ 每次循环都触发重排
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  ul.appendChild(li); // 1000 次重排
}

// ✅ 使用 DocumentFragment,只触发一次重排
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.appendChild(li);
}
ul.appendChild(fragment); // 1 次重排

3.3 属性操作

// HTML 属性 vs DOM 属性
// HTML 属性:el.getAttribute / el.setAttribute
// DOM 属性:el.id, el.className, el.value

// 关键区别:
// - HTML 属性是初始值,DOM 属性是当前值
// - input.getAttribute('value') → 初始 HTML 值
// - input.value → 用户输入后的当前值

// dataset — 自定义数据属性
el.dataset.userId = '123';  // 设置 data-user-id="123"
el.dataset.userId;           // 读取 "123"

// classList — 类名操作
el.classList.add('active', 'visible');
el.classList.remove('hidden');
el.classList.toggle('open');
el.classList.contains('active'); // true
el.classList.replace('old', 'new');

四、事件系统深度解析

4.1 事件注册

// ✅ 推荐:addEventListener
const btn = document.querySelector('button');

function handleClick(event) {
  console.log('clicked', event.target);
}

btn.addEventListener('click', handleClick);

// 可以注册多个监听器
btn.addEventListener('click', anotherHandler);

// 第三个参数:选项对象
btn.addEventListener('click', handleClick, {
  capture: false,  // 是否在捕获阶段触发
  once: true,      // 只触发一次后自动移除
  passive: true,   // 承诺不调用 preventDefault(滚动优化)
  signal: controller.signal  // 通过 AbortController 移除
});

// ❌ 避免:onclick 属性(只能绑定一个处理器)
btn.onclick = handleClick;

// ❌ 绝对避免:内联事件处理
// <button onclick="handleClick()">

4.2 事件对象

element.addEventListener('click', (event) => {
  // 通用属性
  event.type;            // 'click'
  event.target;          // 触发事件的原始元素
  event.currentTarget;   // 绑定监听器的元素
  event.timeStamp;       // 事件创建的时间戳
  event.isTrusted;       // 是否由用户触发(非脚本模拟)

  // 鼠标事件属性
  event.clientX;         // 相对视口的 X 坐标
  event.clientY;         // 相对视口的 Y 坐标
  event.pageX;           // 相对文档的 X 坐标
  event.button;          // 0=左键 1=中键 2=右键

  // 键盘事件属性
  event.key;             // 'Enter', 'a', 'Shift'
  event.code;            // 'KeyA'(物理键位,不受布局影响)
  event.ctrlKey;         // 是否按住 Ctrl
  event.shiftKey;
  event.altKey;
  event.metaKey;         // Mac 的 Command 键
});

4.3 事件传播:捕获与冒泡

事件传播分三个阶段:

                    ┌─────────────┐
        ┌──────────│  Window      │──────────┐
        │ CAPTURE  │  Document    │ BUBBLE   │
        │ PHASE    │  <html>      │ PHASE    │
        │ (向下)   │  <body>      │ (向上)   │
        │  ↓       │  <div>       │  ↑       │
        │  ↓       │  <button> ← TARGET      │
        └──────────│             │──────────┘
                    └─────────────┘

阶段 1 — 捕获(Capturing):从 Window 向下传播到目标元素 阶段 2 — 目标(Target):到达触发事件的元素 阶段 3 — 冒泡(Bubbling):从目标元素向上传播到 Window

// 默认:在冒泡阶段触发
parent.addEventListener('click', handler);

// 在捕获阶段触发
parent.addEventListener('click', handler, true);
// 或
parent.addEventListener('click', handler, { capture: true });

4.4 阻止传播与默认行为

// 阻止事件继续传播(冒泡/捕获)
event.stopPropagation();

// 阻止同一元素上的其他监听器执行
event.stopImmediatePropagation();

// 阻止浏览器默认行为
event.preventDefault();

// 常见使用场景
form.addEventListener('submit', (e) => {
  e.preventDefault(); // 阻止表单提交,用 AJAX 代替
});

link.addEventListener('click', (e) => {
  e.preventDefault(); // 阻止链接跳转,用 SPA 路由代替
});

4.5 事件委托(Event Delegation)

事件委托是利用冒泡机制的核心模式——在父元素上监听子元素的事件:

// ❌ 给每个列表项单独绑定(1000 个监听器)
document.querySelectorAll('.item').forEach(item => {
  item.addEventListener('click', handleItemClick);
});

// ✅ 事件委托(1 个监听器)
document.querySelector('.list').addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item) return; // 点击的不是列表项

  handleItemClick(item);
});

事件委托的优势

优势 说明
内存效率 1 个监听器 vs N 个监听器
动态元素 后续添加的子元素自动被覆盖,无需重新绑定
简化管理 只需在一处添加/移除监听器

注意:不是所有事件都冒泡。focusblurmouseentermouseleave 不冒泡,但可以用 focusinfocusoutmouseovermouseout 替代(这些会冒泡)。


五、自定义事件

// 创建自定义事件
const event = new CustomEvent('user:login', {
  detail: { userId: 123, username: 'alice' },
  bubbles: true,      // 是否冒泡
  cancelable: true,   // 是否可以 preventDefault
  composed: true       // 是否穿越 Shadow DOM 边界
});

// 派发事件
element.dispatchEvent(event);

// 监听自定义事件
document.addEventListener('user:login', (e) => {
  console.log('用户登录:', e.detail.username);
});

六、MutationObserver

MutationObserver 可以监听 DOM 变化——比事件监听更底层、更全面:

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    switch (mutation.type) {
      case 'childList':
        console.log('子节点变化:', mutation.addedNodes, mutation.removedNodes);
        break;
      case 'attributes':
        console.log('属性变化:', mutation.attributeName);
        break;
      case 'characterData':
        console.log('文本变化:', mutation.target.textContent);
        break;
    }
  }
});

observer.observe(targetNode, {
  childList: true,     // 监听子节点增删
  attributes: true,    // 监听属性变化
  characterData: true, // 监听文本内容变化
  subtree: true,       // 监听所有后代节点
  attributeFilter: ['class', 'data-state'], // 只监听特定属性
  attributeOldValue: true, // 记录旧值
});

// 停止观察
observer.disconnect();

// 获取尚未处理的变更记录
const pending = observer.takeRecords();

使用场景

  • 监听第三方库对 DOM 的修改
  • 实现自动化的可访问性检查
  • 构建"DOM 变化日志"用于调试

七、性能关键点

7.1 重排(Reflow)与重绘(Repaint)

操作类型 触发 代价
重排(Reflow) 修改几何属性(width、height、margin、position) 高——需要重新计算布局
重绘(Repaint) 修改外观属性(color、background、visibility) 中——只需重新绘制
合成(Composite) transform、opacity 低——仅 GPU 合成

7.2 强制同步布局

// ❌ 读写交替导致强制同步布局
for (const el of elements) {
  el.style.width = box.offsetWidth + 'px'; // 每次循环都强制布局
}

// ✅ 先读后写
const width = box.offsetWidth; // 一次读取
for (const el of elements) {
  el.style.width = width + 'px'; // 批量写入
}

7.3 使用 AbortController 管理监听器

const controller = new AbortController();

// 注册多个监听器,共享同一个 signal
window.addEventListener('resize', handleResize, { signal: controller.signal });
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
document.addEventListener('click', handleClick, { signal: controller.signal });

// 一次性移除所有监听器
controller.abort();

八、面试高频问题

Q: event.target 和 event.currentTarget 的区别?

回答要点event.target 是触发事件的原始元素(用户实际点击的那个),event.currentTarget绑定监听器的元素。在事件委托中,target 可能是子元素,而 currentTarget 始终是绑定了 addEventListener 的父元素。在事件处理函数中,this(非箭头函数时)等同于 currentTarget

Q: 如何实现一个事件委托?注意什么?

回答要点:在父元素上监听事件,通过 event.targetevent.target.closest(selector) 判断实际触发的子元素。注意事项:(1) 不是所有事件都冒泡;(2) closest() 可以处理点击子元素内部嵌套元素的情况;(3) 需要考虑事件目标为 null 的边界情况。

Q: passive 事件监听器是什么?为什么重要?

回答要点passive: true 告诉浏览器该监听器不会调用 preventDefault(),浏览器因此可以立即执行默认行为(如滚动)而无需等待 JavaScript 执行完毕。这对触摸和滚动事件的性能至关重要——Chrome 默认将 touchstarttouchmove 设为 passive。


九、与其他主题的关联

关联主题 关系
web-components Shadow DOM 中的事件传播有特殊行为(composed 属性)
a11y-fundamentals 键盘事件处理是可访问性的核心
modern-web-apis IntersectionObserver、ResizeObserver 与 MutationObserver 同属观察者 API 家族
event-loop 理解事件回调在事件循环中的调度机制
memory-management 未移除的事件监听器是常见的内存泄漏源

参考资料

延展阅读