跳到主要内容

浏览器事件机制

问题

浏览器的事件机制是如何工作的?什么是事件冒泡和事件捕获?如何实现事件委托?

答案

浏览器事件机制包含三个阶段:捕获阶段、目标阶段、冒泡阶段。理解事件机制是处理用户交互的基础。

事件流

三个阶段

阶段方向说明
捕获阶段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 对 touchstarttouchmove 事件默认 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') {
// 处理点击
}
});

优点

  1. 减少内存占用(只绑定一个监听器)
  2. 动态元素自动支持
  3. 代码更简洁

缺点

  1. 不适用于不冒泡的事件(focus、blur)
  2. 层级太深可能有性能影响
  3. 需要判断目标元素

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/blurmouseenter/mouseleave
动态元素自动支持:新增子元素无需重新绑定层级过深时,closest 查找有轻微性能开销
代码更简洁:不需要为每个子元素绑定/解绑需要额外的 e.target 判断逻辑
便于统一管理:一处绑定,一处移除如果 stopPropagation 被子元素调用,事件无法冒泡到委托元素
实践建议

对于不冒泡的事件,可以使用替代事件:focusfocusinblurfocusoutmouseentermouseovermouseleavemouseout。这些替代事件支持冒泡,可以用于事件委托。

Q7: addEventListener 的第三个参数有哪些选项?passive 有什么作用?

答案

addEventListener 的第三个参数可以是 booleanAddEventListenerOptions 对象:

// 布尔值形式:控制是否在捕获阶段触发
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 级别的 touchstarttouchmove 默认设置 passive: true。如果需要在这些事件中调用 preventDefault(),必须显式设置 { passive: false }

Q8: 如何实现一个自定义事件系统?CustomEvent 和 Event 的区别?

答案

CustomEvent 和 Event 的区别

特性EventCustomEvent
携带数据不支持支持(通过 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 + DOMEventEmitter
依赖需要 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);
});

相关链接