【每日一面】事件模型与事件委托
事件在 DOM 树里先捕获后冒泡,事件委托借着冒泡把 N 个监听器收成一个,顺带让动态节点自动生效。
基础问答
问:JavaScript 的事件模型是什么?事件冒泡和事件捕获分别指什么?
答:JavaScript 的 DOM 事件模型定义了事件在文档树中传播的完整路径,分为三个阶段:
-
捕获阶段:事件从
window逐层向下传播到目标元素的父节点,沿途触发捕获型监听器。 -
目标阶段:事件到达实际触发事件的目标元素。
-
冒泡阶段:事件从目标元素逐层向上传播回
window,沿途触发冒泡型监听器。
事件冒泡(Bubbling):事件从最具体的元素(目标节点)开始,逐级向上传播到最不具体的节点(document/window)。绝大多数事件都会冒泡。
事件捕获(Capturing):事件从最不具体的节点开始,逐级向下传播到目标元素。捕获阶段在实际开发中使用较少,通常用于拦截事件。
问:什么是事件委托?它的核心原理是什么?
答:事件委托(Event Delegation)是一种利用事件冒泡机制的编程模式,即,不直接在每个子元素上绑定事件监听,而是将监听器挂到它们的共同父元素上,通过 event.target 判断实际触发事件的子元素,再执行对应逻辑。
核心原理:事件冒泡 + event.target 判定。
事件委托的好处有三个:
-
减少内存占用:一个父监听器替代 N 个子监听器,降低内存开销。
-
动态节点自动适配:后续动态插入的子节点无需手动绑定事件。
-
代码更简洁:集中管理事件逻辑,方便维护。
扩展延伸
DOM 事件流
W3C 在 DOM2 Events 规范中统一了事件传播模型:先捕获、后冒泡。使用 addEventListener 绑定事件时,第三个参数 useCapture(或 options.capture)决定监听器注册在哪个阶段:
-
false:默认配置,注册在冒泡阶段。
-
true:注册在捕获阶段。
// 捕获阶段监听
element.addEventListener('click', handler, true);
// 冒泡阶段监听(默认)
element.addEventListener('click', handler, false);
// 等价于
element.addEventListener('click', handler);通过 event.eventPhase 属性可以查看当前所处的阶段:
| 常量 | 值 | 阶段 |
|---|---|---|
Event.NONE | 0 | 未触发 |
Event.CAPTURING_PHASE | 1 | 捕获阶段 |
Event.AT_TARGET | 2 | 目标阶段 |
Event.BUBBLING_PHASE | 3 | 冒泡阶段 |
事件捕获与事件冒泡
下面用 Mermaid 图展示一个 div > ul > li 结构中点击 li 时的事件传播路径:
flowchart TD
W[window] -->|捕获阶段 1| D[document]
D -->|捕获阶段 2| B["body"]
B -->|捕获阶段 3| DIV["div#parent"]
DIV -->|捕获阶段 4| UL["ul"]
UL -->|目标阶段 5| LI["li(目标元素)"]
LI -->|冒泡阶段 6| UL
UL -->|冒泡阶段 7| DIV
DIV -->|冒泡阶段 8| B
B -->|冒泡阶段 9| D
D -->|冒泡阶段 10| W
style LI fill:#4ae,stroke:#258,stroke-width:2px
style W fill:#f96,stroke:#c63,stroke-width:1px
style D fill:#f96,stroke:#c63,stroke-width:1px
-
捕获阶段从
window下行到目标元素的父节点,不包括目标元素本身。目标阶段单独存在。 -
目标阶段的监听器按注册顺序执行,和
useCapture无关,但如果同时注册了捕获和冒泡监听器,两者都会在目标阶段执行,通常先注册的先执行。 -
不是所有事件都冒泡。
focus、blur、mouseenter、mouseleave、scroll等事件不冒泡。不冒泡事件的例外处理:
javascript// focus 不冒泡,但 focusin 冒泡 element.addEventListener('focusin', handler); // 可以冒泡 element.addEventListener('focus', handler); // 不冒泡
事件委托
事件委托最常见的应用场景是列表项点击和动态内容的事件绑定。
反面案例:逐个绑定,性能差
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
item.addEventListener('click', () => {
console.log('点击了', item.textContent);
});
});
// 问题:N 个监听器占用内存;动态新增的 item 不会被绑定正确使用:事件委托
const list = document.querySelector('.list');
list.addEventListener('click', (event) => {
const target = event.target.closest('.list-item');
if (!target) return; // 点击的不是 list-item,忽略
console.log('点击了', target.textContent);
});
// 一个监听器搞定所有,动态插入的 item 自动生效event.target 是触发事件的最深层元素,event.currentTarget 是绑定监听器的元素(这里就是 .list)。利用 Element.closest() 可以从 event.target 向上查找匹配选择器的祖先元素,非常适合事件委托的过滤场景。
多类型事件委托:
const form = document.querySelector('#form');
form.addEventListener('click', (e) => {
const btn = e.target.closest('[data-action]');
if (!btn) return;
const action = btn.dataset.action;
switch (action) {
case 'save': save(); break;
case 'delete': delete(); break;
case 'edit': edit(); break;
}
});对比表格
| 对比维度 | 直接绑定 | 事件委托 |
|---|---|---|
| 监听器数量 | N 个(每个子元素一个) | 1 个(父元素上) |
| 内存占用 | 高 | 低 |
| 动态元素支持 | 需重新绑定 | 自动生效 |
| event.target 判定 | 不需要 | 需要自行过滤 |
| 适用场景 | 少量静态元素 | 大量元素或动态列表 |
| 事件类型限制 | 所有事件 | 仅限冒泡事件 |
| 代码耦合度 | 高(分散在各处) | 低(集中管理) |
面试追问
追问 1:stopPropagation 和 stopImmediatePropagation 有什么区别?
两者都阻止事件继续传播,但 stopImmediatePropagation 更强硬。
// stopPropagation —— 阻止后续传播,但当前元素上其他监听器仍会执行
element.addEventListener('click', function (e) {
e.stopPropagation();
console.log('handler 1'); // 会执行
});
element.addEventListener('click', function () {
console.log('handler 2'); // 也会执行,stopPropagation 不阻止同级监听器
});
// stopImmediatePropagation —— 阻止传播 + 阻止当前元素上未执行的监听器
element.addEventListener('click', function (e) {
e.stopImmediatePropagation();
console.log('handler A'); // 会执行
});
element.addEventListener('click', function () {
console.log('handler B'); // 不会执行!被上方的 stopImmediatePropagation 阻止了
});对比:
| 方法 | 阻止传播 | 阻止同级监听器 |
|---|---|---|
stopPropagation() | 是 | 否 |
stopImmediatePropagation() | 是 | 是 |
追问 2:以下代码输出什么?为什么?
<div id="outer">
outer
<div id="inner">inner</div>
</div>const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
outer.addEventListener('click', () => console.log('outer 捕获'), true);
outer.addEventListener('click', () => console.log('outer 冒泡'));
inner.addEventListener('click', () => console.log('inner 捕获'), true);
inner.addEventListener('click', () => console.log('inner 冒泡'));输出顺序:
outer 捕获
inner 捕获
inner 冒泡
outer 冒泡分析:
-
点击
#inner,事件从window下行。捕获阶段先触发#outer的捕获监听器(useCapture: true),输出 outer 捕获。 -
到达目标阶段(
#inner),按注册顺序执行:先注册的捕获监听器先输出 inner 捕获,再输出 inner 冒泡(目标阶段不区分捕获/冒泡,按注册顺序)。 -
进入冒泡阶段,事件回到
#outer,触发冒泡监听器(useCapture: false),输出 outer 冒泡。
追问 3:如果一个父节点有 10000 个子节点需要绑定点击事件,怎么优化?
这是事件委托的经典场景。
最直接的优化,事件委托:
const parent = document.querySelector('.parent');
parent.addEventListener('click', (e) => {
const item = e.target.closest('.item');
if (!item) return;
// 处理点击
});如果列表极其巨大且频繁滚动/插入删除,还可以进一步:
-
配合
passive: true:如果不需要调用preventDefault(),添加{ passive: true }提示浏览器不等待监听器返回,提升滚动性能。javascriptparent.addEventListener('click', handler, { passive: true }); -
使用
requestAnimationFrame节流:如果每个点击的处理逻辑较重(例如 DOM 操作 + 网络请求),可以跟一个 rAF 帧对齐:javascriptlet pending = null; parent.addEventListener('click', (e) => { if (pending) return; pending = true; requestAnimationFrame(() => { pending = false; const item = e.target.closest('.item'); if (item) handleClick(item); }); }); -
虚拟滚动 + 事件委托配合:对于真正海量的列表(10 万级别),事件委托仍然只是绑定了父节点一个监听器,此时真正的瓶颈是 DOM 元素太多,应该先上虚拟滚动,再在容器上做事件委托。
追问 4:手写一个通用的事件委托函数,支持过滤特定子元素
/**
* 创建一个事件委托监听器
* @param {HTMLElement} parent - 父容器
* @param {string} eventType - 事件类型
* @param {string} selector - CSS 选择器,用于过滤子元素
* @param {Function} handler - 事件处理函数,接收 (event, matchedElement)
* @param {boolean|Object} options - 传给 addEventListener 的选项
*/
function delegate(parent, eventType, selector, handler, options = false) {
parent.addEventListener(eventType, (event) => {
const target = event.target.closest(selector);
// 确保目标元素在父容器内(closest 可能匹配到父容器之外)
if (target && parent.contains(target)) {
handler.call(target, event, target);
}
}, options);
}
// 使用示例
delegate(document.getElementById('list'), 'click', '.item', (e, el) => {
console.log('点击了', el.textContent);
});
delegate(document.getElementById('toolbar'), 'click', '[data-action="delete"]', () => {
console.log('执行删除操作');
});扩展:支持多个选择器映射:
function delegateMap(parent, eventType, mapping, options = false) {
parent.addEventListener(eventType, (event) => {
const target = event.target;
for (const [selector, handler] of Object.entries(mapping)) {
const matched = target.closest(selector);
if (matched && parent.contains(matched)) {
handler.call(matched, event, matched);
break; // 命中第一个匹配就停止
}
}
}, options);
}
delegateMap(document.getElementById('app'), 'click', {
'.save-btn': () => console.log('保存'),
'.delete-btn': () => console.log('删除'),
'.edit-btn': () => console.log('编辑'),
});追问 5:React 的合成事件与原生 DOM 事件有什么异同?
React 实现了一套合成事件(SyntheticEvent)系统,核心差异如下:
相似点:
-
接口一致:
stopPropagation()、preventDefault()等方法名与原生事件相同。 -
事件类型映射:React 的
onClick、onChange等对应原生事件。
核心差异:
| 对比维度 | 原生 DOM 事件 | React 合成事件 |
|---|---|---|
| 绑定方式 | addEventListener 或 onclick 属性 | JSX 属性:onClick={handler} |
| 事件委托 | 手动实现 | 自动委托到 root 容器(React 16 挂 document,React 18 挂 root) |
| 事件池 | 无 | React 16 有事件池(e.persist() 取出),React 17+ 已移除 |
this 绑定 | 指向元素 | 默认 undefined(函数组件);类组件需 .bind 或箭头函数 |
| 阻止默认行为 | return false 或 preventDefault() | 必须显式调用 preventDefault() |
| 异步访问事件对象 | 可直接访问 | React 17+ 可以直接异步访问(16 需 e.persist()) |
React 17 的变化:
React 17 将事件委托从 document 移到了 React 渲染的根容器(#root),主要收益:
// React 16:所有合成事件委托到 document
// React 17+:委托到 root 容器
const root = ReactDOM.createRoot(document.getElementById('root'));-
嵌套 React 应用更安全:不同 React 版本的应用不会相互干扰对方的合成事件。
-
与 Shadow DOM 兼容性更好:事件不会逃逸到 Shadow DOM 边界外。
合成事件的冒泡行为:
function App() {
return (
<div onClick={() => console.log('outer')}>
<button onClick={(e) => {
e.stopPropagation(); // 阻止合成事件冒泡
console.log('inner');
}}>
点击
</button>
</div>
);
}
// 点击后只输出 "inner",因为 stopPropagation 阻止了合成事件冒泡
// 但原生事件仍会冒泡到 document(React 17 之前)