【每日一面】事件模型与事件委托

2026-09-16 22 min 7694 字 -- 次阅读
摘要

事件在 DOM 树里先捕获后冒泡,事件委托借着冒泡把 N 个监听器收成一个,顺带让动态节点自动生效。

基础问答

问:JavaScript 的事件模型是什么?事件冒泡和事件捕获分别指什么?

:JavaScript 的 DOM 事件模型定义了事件在文档树中传播的完整路径,分为三个阶段:

  1. 捕获阶段:事件从 window 逐层向下传播到目标元素的父节点,沿途触发捕获型监听器。

  2. 目标阶段:事件到达实际触发事件的目标元素。

  3. 冒泡阶段:事件从目标元素逐层向上传播回 window,沿途触发冒泡型监听器。

事件冒泡(Bubbling):事件从最具体的元素(目标节点)开始,逐级向上传播到最不具体的节点(document/window)。绝大多数事件都会冒泡。

事件捕获(Capturing):事件从最不具体的节点开始,逐级向下传播到目标元素。捕获阶段在实际开发中使用较少,通常用于拦截事件。

问:什么是事件委托?它的核心原理是什么?

:事件委托(Event Delegation)是一种利用事件冒泡机制的编程模式,即,不直接在每个子元素上绑定事件监听,而是将监听器挂到它们的共同父元素上,通过 event.target 判断实际触发事件的子元素,再执行对应逻辑。

核心原理:事件冒泡 + event.target 判定

事件委托的好处有三个:

  1. 减少内存占用:一个父监听器替代 N 个子监听器,降低内存开销。

  2. 动态节点自动适配:后续动态插入的子节点无需手动绑定事件。

  3. 代码更简洁:集中管理事件逻辑,方便维护。

扩展延伸

DOM 事件流

W3C 在 DOM2 Events 规范中统一了事件传播模型:先捕获、后冒泡。使用 addEventListener 绑定事件时,第三个参数 useCapture(或 options.capture)决定监听器注册在哪个阶段:

  • false:默认配置,注册在冒泡阶段。

  • true:注册在捕获阶段。

javascript
// 捕获阶段监听
element.addEventListener('click', handler, true);

// 冒泡阶段监听(默认)
element.addEventListener('click', handler, false);
// 等价于
element.addEventListener('click', handler);

通过 event.eventPhase 属性可以查看当前所处的阶段:

常量阶段
Event.NONE0未触发
Event.CAPTURING_PHASE1捕获阶段
Event.AT_TARGET2目标阶段
Event.BUBBLING_PHASE3冒泡阶段

事件捕获与事件冒泡

下面用 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 无关,但如果同时注册了捕获和冒泡监听器,两者都会在目标阶段执行,通常先注册的先执行

  • 不是所有事件都冒泡。focusblurmouseentermouseleavescroll 等事件不冒泡。

    不冒泡事件的例外处理

    javascript
    // focus 不冒泡,但 focusin 冒泡
    element.addEventListener('focusin', handler); // 可以冒泡
    element.addEventListener('focus', handler);   // 不冒泡

事件委托

事件委托最常见的应用场景是列表项点击动态内容的事件绑定

反面案例:逐个绑定,性能差

javascript
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
  item.addEventListener('click', () => {
    console.log('点击了', item.textContent);
  });
});
// 问题:N 个监听器占用内存;动态新增的 item 不会被绑定

正确使用:事件委托

javascript
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 向上查找匹配选择器的祖先元素,非常适合事件委托的过滤场景。

多类型事件委托

javascript
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:stopPropagationstopImmediatePropagation 有什么区别?

两者都阻止事件继续传播,但 stopImmediatePropagation 更强硬。

javascript
// 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:以下代码输出什么?为什么?

xml
<div id="outer">
  outer
  <div id="inner">inner</div>
</div>
javascript
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 冒泡'));

输出顺序

plaintext
outer 捕获
inner 捕获
inner 冒泡
outer 冒泡

分析

  1. 点击 #inner,事件从 window 下行。捕获阶段先触发 #outer 的捕获监听器(useCapture: true),输出 outer 捕获。

  2. 到达目标阶段(#inner),按注册顺序执行:先注册的捕获监听器先输出 inner 捕获,再输出 inner 冒泡(目标阶段不区分捕获/冒泡,按注册顺序)。

  3. 进入冒泡阶段,事件回到 #outer,触发冒泡监听器(useCapture: false),输出 outer 冒泡。

追问 3:如果一个父节点有 10000 个子节点需要绑定点击事件,怎么优化?

这是事件委托的经典场景。

最直接的优化,事件委托

javascript
const parent = document.querySelector('.parent');
parent.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item) return;
  // 处理点击
});

如果列表极其巨大且频繁滚动/插入删除,还可以进一步:

  1. 配合 passive: true:如果不需要调用 preventDefault(),添加 { passive: true } 提示浏览器不等待监听器返回,提升滚动性能。

    javascript
    parent.addEventListener('click', handler, { passive: true });
  2. 使用 requestAnimationFrame 节流:如果每个点击的处理逻辑较重(例如 DOM 操作 + 网络请求),可以跟一个 rAF 帧对齐:

    javascript
    let pending = null;
    parent.addEventListener('click', (e) => {
      if (pending) return;
      pending = true;
      requestAnimationFrame(() => {
        pending = false;
        const item = e.target.closest('.item');
        if (item) handleClick(item);
      });
    });
  3. 虚拟滚动 + 事件委托配合:对于真正海量的列表(10 万级别),事件委托仍然只是绑定了父节点一个监听器,此时真正的瓶颈是 DOM 元素太多,应该先上虚拟滚动,再在容器上做事件委托。

追问 4:手写一个通用的事件委托函数,支持过滤特定子元素

javascript
/**
 * 创建一个事件委托监听器
 * @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('执行删除操作');
});

扩展:支持多个选择器映射

javascript
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 的 onClickonChange 等对应原生事件。

核心差异

对比维度原生 DOM 事件React 合成事件
绑定方式addEventListeneronclick 属性JSX 属性:onClick={handler}
事件委托手动实现自动委托到 root 容器(React 16 挂 document,React 18 挂 root)
事件池React 16 有事件池(e.persist() 取出),React 17+ 已移除
this 绑定指向元素默认 undefined(函数组件);类组件需 .bind 或箭头函数
阻止默认行为return falsepreventDefault()必须显式调用 preventDefault()
异步访问事件对象可直接访问React 17+ 可以直接异步访问(16 需 e.persist()

React 17 的变化

React 17 将事件委托从 document 移到了 React 渲染的根容器(#root),主要收益:

javascript
// React 16:所有合成事件委托到 document
// React 17+:委托到 root 容器
const root = ReactDOM.createRoot(document.getElementById('root'));
  1. 嵌套 React 应用更安全:不同 React 版本的应用不会相互干扰对方的合成事件。

  2. 与 Shadow DOM 兼容性更好:事件不会逃逸到 Shadow DOM 边界外。

合成事件的冒泡行为

javascript
function App() {
  return (
    <div onClick={() => console.log('outer')}>
      <button onClick={(e) => {
        e.stopPropagation(); // 阻止合成事件冒泡
        console.log('inner');
      }}>
        点击
      </button>
    </div>
  );
}
// 点击后只输出 "inner",因为 stopPropagation 阻止了合成事件冒泡
// 但原生事件仍会冒泡到 document(React 17 之前)
评论