浏览器篇 | DOM事件模型
前言
点击一个按钮,浏览器怎么知道该触发哪个事件处理函数?如果按钮在一个 <div> 里面,<div> 上也绑了点击事件,两个回调的执行顺序是什么?为什么有的人用 event.stopPropagation(),有的人用 event.preventDefault(),它们到底有什么区别?
这些问题的答案,都藏在 DOM 事件模型中。
DOM 事件模型定义了事件从”触发”到”处理”的完整流程。理解它,你不仅能写出更高效的事件处理代码(比如事件委托),还能精准控制事件的传播行为,避免那些”点了一下触发了好几个回调”的诡异 bug。
面试中,事件模型的考点通常围绕几个核心概念展开:事件流三阶段、事件委托、事件对象的方法。本章我们逐一拆解。
诊断自测
Q1:DOM 事件流的三个阶段是什么?说出它们的执行顺序。
点击查看答案
三个阶段依次是:捕获阶段(Capturing)→ 目标阶段(Target)→ 冒泡阶段(Bubbling)。
当一个事件发生时,浏览器会从 window 开始逐层向下”捕获”到目标元素,然后再从目标元素逐层向上”冒泡”回 window。大多数事件处理器默认注册在冒泡阶段。
Q2:event.target 和 event.currentTarget 有什么区别?
点击查看答案
event.target 是实际触发事件的元素(比如用户真正点击到的那个元素),在事件传播过程中不会变。event.currentTarget 是当前正在处理事件的元素(即绑定了事件处理器的那个元素),在不同的处理器中它会变。在事件委托场景中,target 通常是子元素,而 currentTarget 是绑定了处理器的父元素。
一、事件流三阶段
1.1 捕获 → 目标 → 冒泡
当你点击页面上的一个按钮时,这个点击事件不是直接”到达”按钮的。浏览器会按照以下流程传播事件:
① 捕获阶段(从外到内)
Window → Document → <html> → <body> → <div> → <button>
│
② 目标阶段
│
③ 冒泡阶段(从内到外)
Window ← Document ← <html> ← <body> ← <div> ← <button>
捕获阶段(Capturing Phase): 事件从 window 出发,沿着 DOM 树一路向下,直到到达目标元素。这个阶段是”由外到内”的。
目标阶段(Target Phase): 事件到达了实际触发事件的元素(event.target)。注册在目标元素上的处理器会按注册顺序执行(无论是捕获还是冒泡注册的)。
冒泡阶段(Bubbling Phase): 事件从目标元素出发,沿着 DOM 树一路向上,回到 window。这个阶段是”由内到外”的。
关键概念:大多数事件默认注册在冒泡阶段。 这就是为什么当你点击一个子元素时,父元素上的事件处理器也会触发——因为事件”冒泡”到了父元素。
1.2 一个直观的例子
<div id="outer">
<div id="inner">
<button id="btn">Click me</button>
</div>
</div>
const outer = document.getElementById('outer');
const inner = document.getElementById('inner');
const btn = document.getElementById('btn');
// 冒泡阶段(默认)
outer.addEventListener('click', () => console.log('outer 冒泡'));
inner.addEventListener('click', () => console.log('inner 冒泡'));
btn.addEventListener('click', () => console.log('btn 冒泡'));
// 捕获阶段
outer.addEventListener('click', () => console.log('outer 捕获'), true);
inner.addEventListener('click', () => console.log('inner 捕获'), true);
btn.addEventListener('click', () => console.log('btn 捕获'), true);
点击 button 后,输出顺序:
outer 捕获 ← 捕获阶段
inner 捕获 ← 捕获阶段
btn 冒泡 ← 目标阶段(按注册顺序)
btn 捕获 ← 目标阶段(按注册顺序)
inner 冒泡 ← 冒泡阶段
outer 冒泡 ← 冒泡阶段
注意在目标阶段,不区分捕获和冒泡——处理器按注册顺序执行。因为 btn 上先注册了冒泡的处理器,后注册了捕获的处理器,所以”btn 冒泡”在”btn 捕获”之前输出。
1.3 不会冒泡的事件
大多数事件都会冒泡,但有一些例外:
focus/blur(替代品:focusin/focusout会冒泡)mouseenter/mouseleave(替代品:mouseover/mouseout会冒泡)load/unload/resize/scroll(在某些元素上不冒泡)
可以通过 event.bubbles 属性来判断一个事件是否会冒泡。
二、addEventListener 的第三个参数
addEventListener 的完整签名:
element.addEventListener(type, listener, options);
第三个参数可以是一个布尔值或一个选项对象。
2.1 布尔值形式
element.addEventListener('click', handler, true); // 在捕获阶段触发
element.addEventListener('click', handler, false); // 在冒泡阶段触发(默认)
element.addEventListener('click', handler); // 等同于 false
2.2 选项对象形式(推荐)
element.addEventListener('click', handler, {
capture: false, // 是否在捕获阶段触发(默认 false)
once: true, // 是否只触发一次(触发后自动 removeEventListener)
passive: true, // 是否为被动监听(下面详细解释)
signal: controller.signal // 用 AbortController 来取消监听
});
once: true 非常实用——处理器触发一次后自动移除,不需要手动 removeEventListener:
button.addEventListener('click', () => {
console.log('只会执行一次');
}, { once: true });
passive: true 告诉浏览器:这个处理器不会调用 preventDefault()。这对滚动性能很重要——浏览器不需要等待处理器执行完毕才能决定是否滚动,可以立即开始滚动,减少卡顿。
// 对 touchstart 和 wheel 事件使用 passive
document.addEventListener('touchstart', handler, { passive: true });
document.addEventListener('wheel', handler, { passive: true });
在 Chrome 中,touchstart 和 touchmove 事件的监听器默认就是 passive 的。如果你在这些事件里调用了 preventDefault(),浏览器会忽略并在控制台给出警告。
signal 可以用 AbortController 来批量移除事件监听器:
const controller = new AbortController();
element.addEventListener('click', handler1, { signal: controller.signal });
element.addEventListener('keydown', handler2, { signal: controller.signal });
element.addEventListener('mousemove', handler3, { signal: controller.signal });
// 一键移除所有监听器
controller.abort();
三、事件委托(事件代理)
3.1 原理
事件委托利用了事件的冒泡机制:把事件处理器绑定在父元素上,通过 event.target 判断实际触发事件的子元素,从而统一处理。
不使用事件委托:
// 给每个 li 都绑定事件 —— 100 个 li 就有 100 个处理器
document.querySelectorAll('li').forEach(li => {
li.addEventListener('click', () => {
console.log(li.textContent);
});
});
使用事件委托:
// 只在 ul 上绑定一个处理器
document.querySelector('ul').addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
console.log(e.target.textContent);
}
});
3.2 事件委托的优势
- 减少内存开销:不需要给每个子元素绑定处理器,一个父元素上的处理器搞定一切
- 动态元素自动生效:后续通过 JS 添加的新子元素,不需要再手动绑定事件,因为事件会冒泡到父元素
- 简化代码:统一在一个地方管理事件逻辑
3.3 实现一个通用的事件委托函数
面试中经常要求手写事件委托:
function delegate(parent, selector, eventType, handler) {
parent.addEventListener(eventType, (e) => {
// 从 target 开始,向上找匹配 selector 的元素
let target = e.target;
while (target && target !== parent) {
if (target.matches(selector)) {
handler.call(target, e);
return;
}
target = target.parentNode;
}
});
}
// 使用
delegate(document.querySelector('ul'), 'li', 'click', function(e) {
console.log('点击了:', this.textContent); // this 是匹配的 li 元素
});
为什么需要 while 循环向上查找?
因为 event.target 可能是 li 的子元素(比如 li 里面有一个 <span>),直接判断 e.target.matches('li') 会返回 false。所以需要从 target 开始,沿着 DOM 树往上找,直到找到匹配 selector 的祖先元素或者到达 parent。
3.4 事件委托的局限
- 不冒泡的事件不能委托:比如
focus、blur(可以用focusin、focusout替代) - 有些场景需要精确定位目标元素:如果子元素结构复杂,通过
event.target判断可能比较繁琐 stopPropagation会中断委托:如果子元素内部调用了stopPropagation,事件不会冒泡到父元素,委托就失效了
四、event.target vs event.currentTarget
这两个属性在事件传播过程中有明确的分工:
| 属性 | 含义 | 特点 |
|---|---|---|
event.target | 实际触发事件的元素 | 在整个事件传播过程中不变 |
event.currentTarget | 当前正在处理事件的元素 | 在不同处理器中会变 |
<div id="parent">
<button id="child">Click</button>
</div>
document.getElementById('parent').addEventListener('click', (e) => {
console.log('target:', e.target.id); // 'child'(点击的是 button)
console.log('currentTarget:', e.currentTarget.id); // 'parent'(处理器绑在 parent 上)
});
在事件处理器中,this 和 event.currentTarget 是同一个元素(除非用了箭头函数——箭头函数没有自己的 this)。
parent.addEventListener('click', function(e) {
console.log(this === e.currentTarget); // true
});
parent.addEventListener('click', (e) => {
console.log(this === e.currentTarget); // false,箭头函数的 this 是外层
});
五、stopPropagation vs stopImmediatePropagation vs preventDefault
这三个方法是面试高频考点,很多人搞混它们的区别。
5.1 event.stopPropagation()
阻止事件继续传播(无论是捕获还是冒泡方向)。但不会阻止当前元素上其他处理器的执行。
inner.addEventListener('click', (e) => {
e.stopPropagation();
console.log('inner handler 1'); // ✅ 执行
});
inner.addEventListener('click', () => {
console.log('inner handler 2'); // ✅ 也会执行!
});
outer.addEventListener('click', () => {
console.log('outer handler'); // ❌ 不会执行(传播被阻止)
});
5.2 event.stopImmediatePropagation()
不仅阻止事件传播,还会阻止当前元素上后续处理器的执行。比 stopPropagation 更”激进”。
inner.addEventListener('click', (e) => {
e.stopImmediatePropagation();
console.log('inner handler 1'); // ✅ 执行
});
inner.addEventListener('click', () => {
console.log('inner handler 2'); // ❌ 不会执行!
});
outer.addEventListener('click', () => {
console.log('outer handler'); // ❌ 不会执行
});
5.3 event.preventDefault()
阻止浏览器的默认行为,但不影响事件传播。
常见的默认行为:
<a>标签的跳转- 表单的提交
- 右键菜单
- checkbox 的勾选/取消
// 阻止链接跳转
link.addEventListener('click', (e) => {
e.preventDefault();
console.log('链接不会跳转,但事件照常冒泡');
});
// 阻止表单提交
form.addEventListener('submit', (e) => {
e.preventDefault();
// 手动处理表单逻辑
});
// 阻止右键菜单
document.addEventListener('contextmenu', (e) => {
e.preventDefault();
// 显示自定义菜单
});
5.4 三者对比
| 方法 | 阻止传播 | 阻止同元素后续处理器 | 阻止默认行为 |
|---|---|---|---|
stopPropagation() | ✅ | ❌ | ❌ |
stopImmediatePropagation() | ✅ | ✅ | ❌ |
preventDefault() | ❌ | ❌ | ✅ |
它们可以组合使用。 比如你想同时阻止传播和默认行为:
element.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
});
六、自定义事件(CustomEvent)
除了浏览器内置的事件(click、keydown 等),我们还可以创建和触发自定义事件。
6.1 基本用法
// 创建自定义事件
const myEvent = new CustomEvent('build', {
detail: { message: 'Hello World', time: Date.now() }, // 自定义数据
bubbles: true, // 是否冒泡
cancelable: true // 是否可以 preventDefault
});
// 监听
element.addEventListener('build', (e) => {
console.log('收到自定义事件:', e.detail);
});
// 触发
element.dispatchEvent(myEvent);
6.2 CustomEvent vs Event
// Event:不能携带自定义数据
const evt = new Event('myEvent', { bubbles: true });
// CustomEvent:可以通过 detail 携带数据
const customEvt = new CustomEvent('myEvent', {
detail: { key: 'value' },
bubbles: true
});
如果需要传递自定义数据,用 CustomEvent;如果只需要一个事件信号,用 Event 就够了。
6.3 实际使用场景
自定义事件在组件通信中很有用,特别是在不使用框架的原生组件或 Web Components 中:
// 子组件:触发自定义事件通知父组件
class MyComponent extends HTMLElement {
connectedCallback() {
this.querySelector('button').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('item-selected', {
detail: { id: this.dataset.id },
bubbles: true,
composed: true // 允许穿越 Shadow DOM 边界
}));
});
}
}
// 父组件:监听
document.querySelector('my-component').addEventListener('item-selected', (e) => {
console.log('选中了:', e.detail.id);
});
在 Vue 中的 $emit、React 中的回调 props,底层思想和自定义事件是类似的——都是”子组件通知父组件”。
七、事件模型的实战技巧
7.1 移除事件监听的正确姿势
removeEventListener 需要传入同一个函数引用:
// ❌ 错误:匿名函数无法移除
element.addEventListener('click', () => console.log('click'));
element.removeEventListener('click', () => console.log('click')); // 无效!
// ✅ 正确:使用命名函数
function handleClick() { console.log('click'); }
element.addEventListener('click', handleClick);
element.removeEventListener('click', handleClick); // 有效
也可以用 { once: true } 或 AbortController 来更优雅地管理:
// 方式一:once
element.addEventListener('click', handler, { once: true });
// 方式二:AbortController
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
// 需要移除时
controller.abort();
7.2 事件委托 + data 属性
在实际项目中,事件委托经常配合 data-* 属性使用:
<ul id="menu">
<li data-action="save">保存</li>
<li data-action="load">加载</li>
<li data-action="search">搜索</li>
</ul>
document.getElementById('menu').addEventListener('click', (e) => {
const action = e.target.dataset.action;
if (!action) return;
switch (action) {
case 'save': save(); break;
case 'load': load(); break;
case 'search': search(); break;
}
});
这种模式在实际项目中非常常见,代码简洁且易于扩展。
常见误区
误区一:“事件是先冒泡再捕获的”
恰好反了。事件流的顺序是捕获 → 目标 → 冒泡。不过由于大多数时候我们只关注冒泡阶段(addEventListener 默认注册在冒泡阶段),所以很多人只知道冒泡,忽略了捕获阶段的存在。但在面试中,你需要完整地说出三个阶段的顺序。
误区二:“stopPropagation 可以阻止默认行为”
不行。stopPropagation 只阻止事件的传播(冒泡或捕获),和默认行为无关。要阻止默认行为(如链接跳转、表单提交),需要用 preventDefault。两者的职责完全不同:传播是事件在 DOM 树中的”路径”问题,默认行为是浏览器对特定事件的”响应”问题。
误区三:“事件委托只能用于 click 事件”
事件委托可以用于任何会冒泡的事件:click、input、change、keydown、mouseover 等。只有那些不冒泡的事件(如 focus、blur)不能直接委托,但可以用它们的冒泡替代品(focusin、focusout)来实现。
误区四:“event.target 就是绑定事件的元素”
不是。event.target 是实际触发事件的元素,event.currentTarget 才是绑定事件处理器的元素。在事件委托中,这两者通常不同——target 是被点击的子元素,currentTarget 是绑定了处理器的父元素。在非委托的直接绑定中,两者恰好相同,这容易让人产生误解。
小结
本章我们从事件流的三个阶段出发,完整梳理了 DOM 事件模型的核心概念。
核心要点
- 事件流三阶段:捕获(外→内)→ 目标 → 冒泡(内→外)
- addEventListener 第三个参数:布尔值(是否捕获)或选项对象(capture、once、passive、signal)
- 事件委托利用冒泡机制,在父元素上统一处理子元素的事件,减少内存、支持动态元素
- event.target 是实际触发事件的元素,event.currentTarget 是绑定处理器的元素
- stopPropagation 阻止传播;stopImmediatePropagation 阻止传播 + 同元素后续处理器;preventDefault 阻止默认行为——三者互不干涉
- CustomEvent 可以创建和触发自定义事件,通过
detail携带数据 - passive: true 对滚动性能有重要意义
本章思维导图
- 事件流三阶段
- 捕获阶段(window → target)
- 目标阶段(按注册顺序执行)
- 冒泡阶段(target → window)
- 不冒泡的事件:focus/blur/mouseenter/mouseleave
- addEventListener
- 第三个参数
- 布尔值:true 捕获 / false 冒泡(默认)
- 选项对象:capture / once / passive / signal
- passive: true 优化滚动性能
- AbortController 批量移除监听
- 第三个参数
- 事件委托
- 原理:冒泡 + event.target 判断
- 优势:减少内存、支持动态元素
- 实现:while 循环向上查找 + matches
- 局限:不冒泡事件不能委托
- 事件对象
- target vs currentTarget
- stopPropagation:阻止传播
- stopImmediatePropagation:阻止传播 + 同元素后续
- preventDefault:阻止默认行为
- 自定义事件
- CustomEvent + detail 数据
- dispatchEvent 触发
- 适用于组件通信 / Web Components
练习挑战
第一题 ⭐(基础):预测输出顺序
<div id="a">
<div id="b">
<div id="c">Click</div>
</div>
</div>
const a = document.getElementById('a');
const b = document.getElementById('b');
const c = document.getElementById('c');
a.addEventListener('click', () => console.log('a 冒泡'));
b.addEventListener('click', () => console.log('b 冒泡'));
c.addEventListener('click', () => console.log('c 冒泡'));
a.addEventListener('click', () => console.log('a 捕获'), true);
b.addEventListener('click', () => console.log('b 捕获'), true);
c.addEventListener('click', () => console.log('c 捕获'), true);
点击 #c,输出顺序是什么?
点击查看答案
a 捕获
b 捕获
c 冒泡
c 捕获
b 冒泡
a 冒泡
分析:
- 捕获阶段:从外到内,依次触发
a 捕获→b 捕获 - 目标阶段:
#c上的处理器按注册顺序执行。先注册的是冒泡处理器(c 冒泡),后注册的是捕获处理器(c 捕获),所以顺序是c 冒泡→c 捕获 - 冒泡阶段:从内到外,依次触发
b 冒泡→a 冒泡
目标阶段不区分捕获/冒泡,按注册顺序执行,这是这道题的关键。
第二题 ⭐⭐(进阶):实现一个支持 once 和 off 的事件委托
要求:
on(parent, selector, event, handler)—— 注册委托事件- 返回一个
off函数用于取消 - 支持
once选项
点击查看答案
function delegate(parent, selector, eventType, handler, options = {}) {
const { once = false } = options;
const wrapper = (e) => {
let target = e.target;
while (target && target !== parent) {
if (target.matches(selector)) {
handler.call(target, e);
if (once) {
parent.removeEventListener(eventType, wrapper);
}
return;
}
target = target.parentNode;
}
};
parent.addEventListener(eventType, wrapper);
// 返回取消函数
return () => {
parent.removeEventListener(eventType, wrapper);
};
}
// 使用
const off = delegate(
document.querySelector('ul'),
'li',
'click',
function(e) {
console.log('点击了:', this.textContent);
}
);
// 取消委托
off();
// 使用 once
delegate(
document.querySelector('ul'),
'li',
'click',
function(e) {
console.log('只触发一次:', this.textContent);
},
{ once: true }
);
核心要点:用一个 wrapper 函数包装委托逻辑,保存对 wrapper 的引用以便后续 removeEventListener。once 选项在第一次触发后自动移除监听器。
第三题 ⭐⭐⭐(综合):实现一个简单的事件总线(EventBus)
要求:
on(event, handler)—— 监听事件off(event, handler)—— 取消监听emit(event, ...args)—— 触发事件once(event, handler)—— 只监听一次
点击查看答案
class EventBus {
constructor() {
this.events = new Map();
}
on(event, handler) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(handler);
return this; // 支持链式调用
}
off(event, handler) {
if (!this.events.has(event)) return this;
if (!handler) {
// 不传 handler,移除该事件的所有处理器
this.events.delete(event);
} else {
const handlers = this.events.get(event);
const index = handlers.indexOf(handler);
if (index > -1) {
handlers.splice(index, 1);
}
if (handlers.length === 0) {
this.events.delete(event);
}
}
return this;
}
emit(event, ...args) {
if (!this.events.has(event)) return this;
// 复制一份,避免在遍历过程中 once 的 off 导致问题
const handlers = [...this.events.get(event)];
handlers.forEach(handler => {
handler.apply(this, args);
});
return this;
}
once(event, handler) {
const wrapper = (...args) => {
handler.apply(this, args);
this.off(event, wrapper);
};
// 保存原始引用,方便直接用原始 handler 来 off
wrapper._original = handler;
this.on(event, wrapper);
return this;
}
}
// 使用
const bus = new EventBus();
bus.on('login', (user) => {
console.log(`${user.name} 登录了`);
});
bus.once('firstVisit', () => {
console.log('欢迎首次访问!');
});
bus.emit('login', { name: 'Alice' }); // Alice 登录了
bus.emit('firstVisit'); // 欢迎首次访问!
bus.emit('firstVisit'); // 无输出(once 已移除)
核心要点:1)once 用 wrapper 包装,在第一次调用后自动 off;2)emit 时要复制 handlers 数组,避免遍历中修改导致问题;3)支持链式调用让 API 更优雅。这也是发布-订阅模式的一个典型实现。
自我检测
- 能说出事件流的三个阶段及其顺序(捕获 → 目标 → 冒泡),并解释目标阶段的特殊性
- 能解释
addEventListener第三个参数的布尔值和对象形式,以及passive的作用 - 能手写一个事件委托函数,并解释为什么需要向上查找(处理子元素嵌套的情况)
- 能清楚区分
event.target和event.currentTarget - 能准确说出
stopPropagation、stopImmediatePropagation、preventDefault的区别 - 能使用
CustomEvent创建和触发自定义事件 - 能说出至少两种移除事件监听器的方式(命名函数 + removeEventListener、once、AbortController)
- 能实现一个简单的 EventBus(发布-订阅模式)
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90