跳到主要内容
预计阅读 46 分钟

浏览器篇 | DOM事件模型

前言

点击一个按钮,浏览器怎么知道该触发哪个事件处理函数?如果按钮在一个 <div> 里面,<div> 上也绑了点击事件,两个回调的执行顺序是什么?为什么有的人用 event.stopPropagation(),有的人用 event.preventDefault(),它们到底有什么区别?

这些问题的答案,都藏在 DOM 事件模型中。

DOM 事件模型定义了事件从”触发”到”处理”的完整流程。理解它,你不仅能写出更高效的事件处理代码(比如事件委托),还能精准控制事件的传播行为,避免那些”点了一下触发了好几个回调”的诡异 bug。

面试中,事件模型的考点通常围绕几个核心概念展开:事件流三阶段、事件委托、事件对象的方法。本章我们逐一拆解。


诊断自测

Q1:DOM 事件流的三个阶段是什么?说出它们的执行顺序。

点击查看答案

三个阶段依次是:捕获阶段(Capturing)→ 目标阶段(Target)→ 冒泡阶段(Bubbling)

当一个事件发生时,浏览器会从 window 开始逐层向下”捕获”到目标元素,然后再从目标元素逐层向上”冒泡”回 window。大多数事件处理器默认注册在冒泡阶段。

Q2:event.targetevent.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 中,touchstarttouchmove 事件的监听器默认就是 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 事件委托的优势

  1. 减少内存开销:不需要给每个子元素绑定处理器,一个父元素上的处理器搞定一切
  2. 动态元素自动生效:后续通过 JS 添加的新子元素,不需要再手动绑定事件,因为事件会冒泡到父元素
  3. 简化代码:统一在一个地方管理事件逻辑

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 事件委托的局限

  • 不冒泡的事件不能委托:比如 focusblur(可以用 focusinfocusout 替代)
  • 有些场景需要精确定位目标元素:如果子元素结构复杂,通过 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 上)
});

在事件处理器中,thisevent.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 事件”

事件委托可以用于任何会冒泡的事件clickinputchangekeydownmouseover 等。只有那些不冒泡的事件(如 focusblur)不能直接委托,但可以用它们的冒泡替代品(focusinfocusout)来实现。

误区四:“event.target 就是绑定事件的元素”

不是。event.target实际触发事件的元素event.currentTarget 才是绑定事件处理器的元素。在事件委托中,这两者通常不同——target 是被点击的子元素,currentTarget 是绑定了处理器的父元素。在非委托的直接绑定中,两者恰好相同,这容易让人产生误解。


小结

本章我们从事件流的三个阶段出发,完整梳理了 DOM 事件模型的核心概念。

核心要点

  1. 事件流三阶段:捕获(外→内)→ 目标 → 冒泡(内→外)
  2. addEventListener 第三个参数:布尔值(是否捕获)或选项对象(capture、once、passive、signal)
  3. 事件委托利用冒泡机制,在父元素上统一处理子元素的事件,减少内存、支持动态元素
  4. event.target 是实际触发事件的元素,event.currentTarget 是绑定处理器的元素
  5. stopPropagation 阻止传播;stopImmediatePropagation 阻止传播 + 同元素后续处理器;preventDefault 阻止默认行为——三者互不干涉
  6. CustomEvent 可以创建和触发自定义事件,通过 detail 携带数据
  7. passive: true 对滚动性能有重要意义

本章思维导图

DOM 事件模型
  • 事件流三阶段
    • 捕获阶段(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 冒泡

分析:

  1. 捕获阶段:从外到内,依次触发 a 捕获b 捕获
  2. 目标阶段:#c 上的处理器按注册顺序执行。先注册的是冒泡处理器(c 冒泡),后注册的是捕获处理器(c 捕获),所以顺序是 c 冒泡c 捕获
  3. 冒泡阶段:从内到外,依次触发 b 冒泡a 冒泡

目标阶段不区分捕获/冒泡,按注册顺序执行,这是这道题的关键。

第二题 ⭐⭐(进阶):实现一个支持 onceoff 的事件委托

要求:

  1. on(parent, selector, event, handler) —— 注册委托事件
  2. 返回一个 off 函数用于取消
  3. 支持 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 的引用以便后续 removeEventListeneronce 选项在第一次触发后自动移除监听器。

第三题 ⭐⭐⭐(综合):实现一个简单的事件总线(EventBus)

要求:

  1. on(event, handler) —— 监听事件
  2. off(event, handler) —— 取消监听
  3. emit(event, ...args) —— 触发事件
  4. 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.targetevent.currentTarget
  • 能准确说出 stopPropagationstopImmediatePropagationpreventDefault 的区别
  • 能使用 CustomEvent 创建和触发自定义事件
  • 能说出至少两种移除事件监听器的方式(命名函数 + removeEventListener、once、AbortController)
  • 能实现一个简单的 EventBus(发布-订阅模式)

购买课程解锁全部内容

大厂前端面试通关:71 篇构建完整知识体系

¥89.90