浏览器事件机制
问题
浏览器的事件机制是如何工作的?什么是事件冒泡和事件捕获?如何实现事件委托?
答案
浏览器事件机制包含三个阶段:捕获阶段、目标阶段、冒泡阶段。理解事件机制是处理用户交互的基础。
事件流
三个阶段
| 阶段 | 方向 | 说明 |
|---|---|---|
| 捕获阶段 | window → 目标 | 从最外层向目标元素传递 |
| 目标阶段 | 目标元素 | 事件到达目标元素 |
| 冒泡阶段 | 目标 → window | 从目标元素向最外层传递 |
// 事件流演示
document.querySelector('.outer')?.addEventListener('click', () => {
console.log('outer 捕获');
}, true); // true = 捕获阶段
document.querySelector('.outer')?.addEventListener('click', () => {
console.log('outer 冒泡');
}, false); // false = 冒泡阶段(默认)
document.querySelector('.inner')?.addEventListener('click', () => {
console.log('inner 捕获');
}, true);
document.querySelector('.inner')?.addEventListener('click', () => {
console.log('inner 冒泡');
}, false);
// 点击 inner 元素,输出顺序:
// outer 捕获
// inner 捕获
// inner 冒泡
// outer 冒泡
addEventListener
基本语法
target.addEventListener(
type: string, // 事件类型
listener: EventListener, // 事件处理函数
options?: boolean | AddEventListenerOptions
);
interface AddEventListenerOptions {
capture?: boolean; // 是否在捕获阶段触发
once?: boolean; // 是否只触发一次
passive?: boolean; // 是否是被动监听器
signal?: AbortSignal; // 用于移除监听器
}
常用配置
// 捕获阶段监听
element.addEventListener('click', handler, true);
element.addEventListener('click', handler, { capture: true });
// 只触发一次
element.addEventListener('click', handler, { once: true });
// 被动监听器(不会调用 preventDefault)
element.addEventListener('scroll', handler, { passive: true });
// 使用 AbortController 移除
const controller = new AbortController();
element.addEventListener('click', handler, { signal: controller.signal });
// 移除监听器
controller.abort();
移除事件监听
// 必须使用同一个函数引用
const handler = () => console.log('clicked');
element.addEventListener('click', handler);
element.removeEventListener('click', handler); // ✅ 成功移除
// ❌ 无法移除:匿名函数
element.addEventListener('click', () => console.log('clicked'));
element.removeEventListener('click', () => console.log('clicked')); // 无效
// ✅ 使用 AbortController(推荐)
const controller = new AbortController();
element.addEventListener('click', () => {
console.log('clicked');
}, { signal: controller.signal });
// 移除
controller.abort();
事件对象
常用属性
element.addEventListener('click', (event: MouseEvent) => {
// 目标相关
console.log(event.target); // 实际触发事件的元素
console.log(event.currentTarget); // 绑定事件的元素
// 事件类型
console.log(event.type); // 'click'
// 阶段
console.log(event.eventPhase); // 1=捕获, 2=目标, 3=冒泡
// 是否冒泡
console.log(event.bubbles); // true
// 时间戳
console.log(event.timeStamp);
// 鼠标位置
console.log(event.clientX, event.clientY); // 视口坐标
console.log(event.pageX, event.pageY); // 页面坐标
console.log(event.offsetX, event.offsetY); // 相对目标元素
});
target vs currentTarget
// HTML 结构
// <ul id="list">
// <li>Item 1</li>
// <li>Item 2</li>
// </ul>
const list = document.getElementById('list');
list?.addEventListener('click', (e) => {
console.log('target:', e.target); // 点击的 li
console.log('currentTarget:', e.currentTarget); // ul(绑定事件的元素)
});
| 属性 | 说明 | 使用场景 |
|---|---|---|
target | 触发事件的元素 | 事件委托中获取实际点击元素 |
currentTarget | 绑定事件的元素 | 获取监听器所在元素 |
阻止事件行为
stopPropagation
阻止事件传播(捕获或冒泡):
element.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止继续传播
console.log('不会传播到父元素');
});
// 阻止后续同元素的监听器
element.addEventListener('click', (e) => {
e.stopImmediatePropagation(); // 连后续监听器都阻止
});
element.addEventListener('click', () => {
console.log('不会执行'); // 被 stopImmediatePropagation 阻止
});
preventDefault
阻止默认行为:
// 阻止链接跳转
link.addEventListener('click', (e) => {
e.preventDefault();
// 自定义处理
});
// 阻止表单提交
form.addEventListener('submit', (e) => {
e.preventDefault();
// 自定义验证和提交
});
// 阻止右键菜单
element.addEventListener('contextmenu', (e) => {
e.preventDefault();
// 显示自定义菜单
});
return false
// DOM0 方式:return false 等于 preventDefault + stopPropagation
element.onclick = function(e) {
return false; // 阻止默认行为和冒泡
};
// addEventListener 方式:return false 没有效果
element.addEventListener('click', (e) => {
return false; // ❌ 无效
});
事件委托
事件委托利用事件冒泡,将子元素的事件处理委托给父元素。
基本实现
// ❌ 不好:为每个 li 绑定事件
document.querySelectorAll('li').forEach(li => {
li.addEventListener('click', handleClick);
});
// ✅ 好:事件委托
const list = document.getElementById('list');
list?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 判断是否是目标元素
if (target.tagName === 'LI') {
handleClick(target);
}
});
使用 closest 匹配
// 更灵活的匹配
list?.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
// 向上查找匹配的元素
const li = target.closest('li');
if (li && list.contains(li)) {
handleClick(li);
}
});
封装通用委托函数
function delegate<K extends keyof HTMLElementEventMap>(
parent: HTMLElement,
selector: string,
eventType: K,
handler: (e: HTMLElementEventMap[K], target: HTMLElement) => void
): () => void {
const controller = new AbortController();
parent.addEventListener(eventType, (e) => {
const target = (e.target as HTMLElement).closest(selector);
if (target && parent.contains(target)) {
handler(e, target as HTMLElement);
}
}, { signal: controller.signal });
return () => controller.abort();
}
// 使用
const removeListener = delegate(
document.getElementById('list')!,
'li',
'click',
(e, target) => {
console.log('点击了:', target.textContent);
}
);
// 移除
removeListener();
事件委托的优缺点
| 优点 | 缺点 |
|---|---|
| 减少内存占用 | 不适用于不冒泡的事件 |
| 支持动态添加的元素 | 层级过深可能影响性能 |
| 代码更简洁 | 需要判断目标元素 |
以下事件不会冒泡,无法使用事件委托:
focus/blur(可用focusin/focusout替代)mouseenter/mouseleave(可用mouseover/mouseout替代)load/unload/scroll(部分情况)
自定义事件
CustomEvent
// 创建自定义事件
const myEvent = new CustomEvent('my-event', {
detail: { message: 'Hello', timestamp: Date.now() },
bubbles: true, // 是否冒泡
cancelable: true, // 是否可取消
});
// 监听
element.addEventListener('my-event', (e: CustomEvent) => {
console.log(e.detail); // { message: 'Hello', timestamp: ... }
});
// 触发
element.dispatchEvent(myEvent);
实际应用示例
// 定义事件类型
interface AppEvents {
'user:login': { userId: string; name: string };
'user:logout': { userId: string };
'cart:update': { items: CartItem[] };
}
// 类型安全的事件系统
class TypedEventTarget<T extends Record<string, any>> {
private target = new EventTarget();
on<K extends keyof T>(
type: K,
handler: (e: CustomEvent<T[K]>) => void
) {
this.target.addEventListener(
type as string,
handler as EventListener
);
}
emit<K extends keyof T>(type: K, detail: T[K]) {
this.target.dispatchEvent(
new CustomEvent(type as string, { detail })
);
}
}
// 使用
const events = new TypedEventTarget<AppEvents>();
events.on('user:login', (e) => {
console.log('用户登录:', e.detail.name);
});
events.emit('user:login', { userId: '123', name: 'Alice' });
passive 监听器
passive: true 告诉浏览器不会调用 preventDefault(),优化滚动性能。
// 滚动、触摸事件推荐使用 passive
element.addEventListener('touchstart', handler, { passive: true });
element.addEventListener('touchmove', handler, { passive: true });
element.addEventListener('wheel', handler, { passive: true });
element.addEventListener('scroll', handler, { passive: true });
Chrome 对 touchstart 和 touchmove 事件默认 passive: true。如果需要 preventDefault(),必须显式设置:
element.addEventListener('touchmove', (e) => {
e.preventDefault(); // 阻止滚动
}, { passive: false }); // 必须显式设置
常见事件类型
鼠标事件
| 事件 | 说明 | 冒泡 |
|---|---|---|
click | 点击 | ✅ |
dblclick | 双击 | ✅ |
mousedown | 按下 | ✅ |
mouseup | 释放 | ✅ |
mousemove | 移动 | ✅ |
mouseenter | 进入(不冒泡) | ❌ |
mouseleave | 离开(不冒泡) | ❌ |
mouseover | 进入(冒泡) | ✅ |
mouseout | 离开(冒泡) | ✅ |
键盘事件
| 事件 | 说明 | 触发时机 |
|---|---|---|
keydown | 按下 | 持续触发 |
keyup | 释放 | 释放时 |
keypress | 字符键(已废弃) | - |
document.addEventListener('keydown', (e: KeyboardEvent) => {
console.log(e.key); // 'a', 'Enter', 'ArrowUp'
console.log(e.code); // 'KeyA', 'Enter', 'ArrowUp'
console.log(e.ctrlKey); // Ctrl 是否按下
console.log(e.shiftKey);
console.log(e.altKey);
console.log(e.metaKey); // Cmd (Mac) / Win (Windows)
});
表单事件
| 事件 | 说明 |
|---|---|
submit | 表单提交 |
reset | 表单重置 |
input | 输入值变化(实时) |
change | 值变化(失焦后) |
focus | 获得焦点(不冒泡) |
blur | 失去焦点(不冒泡) |
focusin | 获得焦点(冒泡) |
focusout | 失去焦点(冒泡) |
常见面试问题
Q1: 事件冒泡和事件捕获有什么区别?
答案:
| 特性 | 事件捕获 | 事件冒泡 |
|---|---|---|
| 方向 | 从外向内(window → target) | 从内向外(target → window) |
| 触发顺序 | 先 | 后 |
| 默认行为 | 需显式开启 | 默认 |
| 使用场景 | 拦截事件 | 事件委托 |
// 捕获:addEventListener 第三个参数为 true
element.addEventListener('click', handler, true);
// 冒泡:默认行为
element.addEventListener('click', handler, false);
Q2: event.target 和 event.currentTarget 的区别?
答案:
event.target:触发事件的元素(被点击的那个)event.currentTarget:绑定事件的元素(监听器所在的)
// <ul onclick="...">
// <li>Item</li> ← 点击这里
// </ul>
list.addEventListener('click', (e) => {
// 点击 li 时:
e.target; // li(实际点击的)
e.currentTarget; // ul(绑定事件的)
});
Q3: 什么是事件委托?有什么优缺点?
答案:
事件委托是利用事件冒泡,将子元素的事件处理委托给父元素。
// 不用为每个 li 绑定事件
ul.addEventListener('click', (e) => {
if ((e.target as HTMLElement).tagName === 'LI') {
// 处理点击
}
});
优点:
- 减少内存占用(只绑定一个监听器)
- 动态元素自动支持
- 代码更简洁
缺点:
- 不适用于不冒泡的事件(focus、blur)
- 层级太深可能有性能影响
- 需要判断目标元素
Q4: 如何阻止事件冒泡和默认行为?
答案:
element.addEventListener('click', (e) => {
e.stopPropagation(); // 阻止冒泡
e.preventDefault(); // 阻止默认行为
e.stopImmediatePropagation(); // 阻止冒泡 + 同元素后续监听器
});
| 方法 | 作用 |
|---|---|
stopPropagation | 阻止事件继续传播 |
preventDefault | 阻止默认行为(如链接跳转) |
stopImmediatePropagation | 阻止传播 + 同元素其他监听器 |
Q5: passive 监听器是什么?为什么要用?
答案:
passive: true 告诉浏览器这个监听器不会调用 preventDefault()。
为什么需要:
浏览器在滚动/触摸时,需要等待 JS 执行完才能确定是否需要滚动。设置 passive: true 后,浏览器可以立即滚动,不等待 JS。
// 滚动性能优化
element.addEventListener('touchmove', handler, { passive: true });
// 需要阻止滚动时
element.addEventListener('touchmove', (e) => {
e.preventDefault();
}, { passive: false }); // 必须显式设置
Q6: 事件委托的原理和优缺点?实际项目中如何使用?
答案:
事件委托(Event Delegation)是利用事件冒泡机制,将子元素的事件处理统一绑定到父元素上,通过判断 e.target 来确定实际触发事件的子元素。
原理:当子元素触发事件时,事件会沿 DOM 树向上冒泡到父元素。父元素的事件监听器通过 e.target 获取实际触发事件的元素,从而实现统一处理。
// 实际项目示例:Todo 列表
// HTML:
// <ul id="todo-list">
// <li data-id="1">任务一 <button class="delete">删除</button></li>
// <li data-id="2">任务二 <button class="delete">删除</button></li>
// </ul>
const todoList = document.getElementById('todo-list') as HTMLUListElement;
todoList.addEventListener('click', (e: MouseEvent) => {
const target = e.target as HTMLElement;
// e.target:实际点击的元素(可能是 li、button、或 li 内其他元素)
// e.currentTarget:绑定事件的元素(始终是 ul#todo-list)
console.log('target:', target);
console.log('currentTarget:', e.currentTarget);
// 使用 closest 向上查找匹配的元素(比 tagName 判断更健壮)
const deleteBtn = target.closest('.delete');
if (deleteBtn) {
const li = deleteBtn.closest('li');
const id = li?.dataset.id;
console.log('删除任务:', id);
li?.remove();
return;
}
// 点击 li 本身
const li = target.closest('li');
if (li && todoList.contains(li)) {
li.classList.toggle('completed');
}
});
// 动态添加的元素自动拥有事件处理能力,无需重新绑定
function addTodo(text: string, id: number): void {
const li = document.createElement('li');
li.dataset.id = String(id);
li.innerHTML = `${text} <button class="delete">删除</button>`;
todoList.appendChild(li); // 无需额外绑定事件
}
| 优点 | 缺点 |
|---|---|
| 减少内存占用:只绑定一个监听器 | 不适用于不冒泡的事件(focus/blur、mouseenter/mouseleave) |
| 动态元素自动支持:新增子元素无需重新绑定 | 层级过深时,closest 查找有轻微性能开销 |
| 代码更简洁:不需要为每个子元素绑定/解绑 | 需要额外的 e.target 判断逻辑 |
| 便于统一管理:一处绑定,一处移除 | 如果 stopPropagation 被子元素调用,事件无法冒泡到委托元素 |
对于不冒泡的事件,可以使用替代事件:focus → focusin,blur → focusout,mouseenter → mouseover,mouseleave → mouseout。这些替代事件支持冒泡,可以用于事件委托。
Q7: addEventListener 的第三个参数有哪些选项?passive 有什么作用?
答案:
addEventListener 的第三个参数可以是 boolean 或 AddEventListenerOptions 对象:
// 布尔值形式:控制是否在捕获阶段触发
element.addEventListener('click', handler, true); // 捕获阶段
element.addEventListener('click', handler, false); // 冒泡阶段(默认)
// 对象形式:更丰富的配置
interface AddEventListenerOptions {
capture?: boolean; // 是否在捕获阶段触发,默认 false
once?: boolean; // 是否只触发一次,默认 false
passive?: boolean; // 是否为被动监听器,默认 false
signal?: AbortSignal; // 用于移除监听器
}
各选项详解:
// 1. capture:捕获阶段触发
document.addEventListener('click', (e) => {
console.log('document 捕获阶段');
}, { capture: true });
// 2. once:只触发一次,自动移除
const button = document.querySelector('button')!;
button.addEventListener('click', () => {
console.log('只会执行一次');
}, { once: true });
// 第二次点击不会触发
// 3. signal:通过 AbortController 移除监听器(推荐方式)
const controller = new AbortController();
// 可以用一个 controller 管理多个监听器
window.addEventListener('resize', handleResize, { signal: controller.signal });
window.addEventListener('scroll', handleScroll, { signal: controller.signal });
document.addEventListener('click', handleClick, { signal: controller.signal });
// 一次性移除所有监听器
controller.abort();
// 4. passive:被动监听器
element.addEventListener('touchmove', handler, { passive: true });
passive 的作用和原理:
浏览器在处理 touchstart/touchmove/wheel 等事件时,需要等待 JS 回调执行完毕才能确定是否需要调用 preventDefault() 阻止滚动。这个等待过程会导致滚动卡顿。
设置 passive: true 告诉浏览器:"这个监听器绝对不会调用 preventDefault()",浏览器就可以立即开始滚动,不等待 JS 执行。
// ✅ 滚动性能优化:passive 让浏览器立即滚动
window.addEventListener('wheel', (e) => {
// 只是记录滚动位置,不需要阻止默认行为
console.log('scrolling', e.deltaY);
}, { passive: true });
// ❌ 如果在 passive 监听器中调用 preventDefault,会被忽略并在控制台警告
window.addEventListener('touchmove', (e) => {
e.preventDefault(); // ⚠️ 无效!控制台会报警告
}, { passive: true });
// ✅ 确实需要阻止滚动时,显式设置 passive: false
element.addEventListener('touchmove', (e) => {
e.preventDefault(); // 阻止滚动(比如实现自定义拖拽)
}, { passive: false });
Chrome 56+ 对 document 级别的 touchstart 和 touchmove 默认设置 passive: true。如果需要在这些事件中调用 preventDefault(),必须显式设置 { passive: false }。
Q8: 如何实现一个自定义事件系统?CustomEvent 和 Event 的区别?
答案:
CustomEvent 和 Event 的区别:
| 特性 | Event | CustomEvent |
|---|---|---|
| 携带数据 | 不支持 | 支持(通过 detail 属性) |
| 创建方式 | new Event(type, options) | new CustomEvent(type, options) |
| 使用场景 | 简单事件通知 | 需要传递数据的事件 |
// 1. Event:简单事件,不携带数据
const simpleEvent = new Event('my-event', {
bubbles: true, // 是否冒泡
cancelable: true, // 是否可以 preventDefault
});
element.dispatchEvent(simpleEvent);
// 2. CustomEvent:可以携带自定义数据
const customEvent = new CustomEvent('user:login', {
detail: { userId: '123', name: 'Alice', timestamp: Date.now() },
bubbles: true,
cancelable: true,
});
element.addEventListener('user:login', (e: CustomEvent) => {
console.log(e.detail); // { userId: '123', name: 'Alice', timestamp: ... }
});
element.dispatchEvent(customEvent);
实现一个类型安全的自定义事件系统(EventEmitter):
// 类型安全的 EventEmitter
type EventHandler<T = any> = (data: T) => void;
class EventEmitter<Events extends Record<string, any>> {
private listeners = new Map<keyof Events, Set<EventHandler>>();
// 监听事件
on<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
if (!this.listeners.has(event)) {
this.listeners.set(event, new Set());
}
this.listeners.get(event)!.add(handler);
// 返回取消订阅函数
return () => this.off(event, handler);
}
// 只监听一次
once<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): () => void {
const wrapper: EventHandler<Events[K]> = (data) => {
handler(data);
this.off(event, wrapper);
};
return this.on(event, wrapper);
}
// 取消监听
off<K extends keyof Events>(event: K, handler: EventHandler<Events[K]>): void {
this.listeners.get(event)?.delete(handler);
}
// 触发事件
emit<K extends keyof Events>(event: K, data: Events[K]): void {
this.listeners.get(event)?.forEach((handler) => {
handler(data);
});
}
// 移除某个事件的所有监听器
removeAllListeners<K extends keyof Events>(event?: K): void {
if (event) {
this.listeners.delete(event);
} else {
this.listeners.clear();
}
}
}
// 定义事件类型
interface AppEvents {
'user:login': { userId: string; name: string };
'user:logout': { userId: string };
'cart:update': { items: Array<{ id: string; count: number }> };
'notification': { message: string; type: 'info' | 'error' };
}
// 使用
const bus = new EventEmitter<AppEvents>();
// 类型安全:TS 会自动推断参数类型
const unsubscribe = bus.on('user:login', (data) => {
console.log(data.name); // TS 知道 data 是 { userId: string; name: string }
});
bus.emit('user:login', { userId: '1', name: 'Alice' });
// 取消订阅
unsubscribe();
CustomEvent(DOM 事件) vs EventEmitter 的对比:
| 特性 | CustomEvent + DOM | EventEmitter |
|---|---|---|
| 依赖 | 需要 DOM 元素 | 纯 JavaScript,无 DOM 依赖 |
| 冒泡 | 支持冒泡和捕获 | 不支持 |
| 使用场景 | 组件间通过 DOM 通信 | 非 DOM 场景、Node.js、业务逻辑解耦 |
| 性能 | 走 DOM 事件流,稍慢 | 直接回调,更快 |
| 类型安全 | 需要手动断言 | 可以通过泛型实现 |
// DOM CustomEvent 的实际应用场景:Web Components 通信
class MyComponent extends HTMLElement {
connectedCallback(): void {
this.addEventListener('click', () => {
// 向父组件冒泡通知
this.dispatchEvent(new CustomEvent('item:selected', {
detail: { id: this.dataset.id },
bubbles: true, // 冒泡到父组件
composed: true, // 穿透 Shadow DOM 边界
}));
});
}
}
// 父组件监听
document.querySelector('.container')?.addEventListener('item:selected', (e: Event) => {
const detail = (e as CustomEvent).detail;
console.log('选中了:', detail.id);
});