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 变化会反映) |
| 可遍历 | forEach、for...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 个监听器 |
| 动态元素 | 后续添加的子元素自动被覆盖,无需重新绑定 |
| 简化管理 | 只需在一处添加/移除监听器 |
注意:不是所有事件都冒泡。focus、blur、mouseenter、mouseleave 不冒泡,但可以用 focusin、focusout、mouseover、mouseout 替代(这些会冒泡)。
五、自定义事件
// 创建自定义事件
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.target 或 event.target.closest(selector) 判断实际触发的子元素。注意事项:(1) 不是所有事件都冒泡;(2) closest() 可以处理点击子元素内部嵌套元素的情况;(3) 需要考虑事件目标为 null 的边界情况。
Q: passive 事件监听器是什么?为什么重要?
回答要点:passive: true 告诉浏览器该监听器不会调用 preventDefault(),浏览器因此可以立即执行默认行为(如滚动)而无需等待 JavaScript 执行完毕。这对触摸和滚动事件的性能至关重要——Chrome 默认将 touchstart 和 touchmove 设为 passive。
九、与其他主题的关联
| 关联主题 | 关系 |
|---|---|
| web-components | Shadow DOM 中的事件传播有特殊行为(composed 属性) |
| a11y-fundamentals | 键盘事件处理是可访问性的核心 |
| modern-web-apis | IntersectionObserver、ResizeObserver 与 MutationObserver 同属观察者 API 家族 |
| event-loop | 理解事件回调在事件循环中的调度机制 |
| memory-management | 未移除的事件监听器是常见的内存泄漏源 |
参考资料
- MDN Web Docs — Event reference
- MDN Web Docs — Introduction to events
- DOM Living Standard — WHATWG DOM Spec
- Google Developers — Passive event listeners