浏览器通过事件机制与用户交互。点击、滚动、键盘——所有这些行为都经过事件流的三阶段(捕获、目标、冒泡)进行传播。事件代理则利用冒泡机制,用最少的监听器处理最多的交互。

事件流:三个阶段

一个 DOM 事件从触发到完成,经历三个阶段:

捕获阶段:window → document → html → body → ... → 触发的父节点
目标阶段:到达触发事件的元素本身
冒泡阶段:触发元素 → 父节点 → ... → body → html → document → window
// 捕获阶段监听(第三个参数 true)
element.addEventListener('click', handler, true);
 
// 冒泡阶段监听(默认)
element.addEventListener('click', handler, false);
// 等价于
element.addEventListener('click', handler);

大多数场景监听冒泡就够了。捕获阶段主要用于在事件到达目标元素之前拦截——比如全局日志记录、权限校验、在子元素处理之前先做一些预处理。

不是所有事件都会冒泡。focusblurscrollmouseentermouseleaveloadunload 等事件不会冒泡。focusblur 有对应的冒泡版本 focusinfocusout

事件绑定

const btn = document.getElementById('btn1');
 
// 标准绑定
btn.addEventListener('click', (e) => {
  console.log('clicked', e.target);
});
 
// 配置选项
btn.addEventListener('click', handler, {
  once: true, // 只触发一次
  passive: true, // 不调用 preventDefault(),暗示浏览器可以优化滚动性能
  capture: true, // 在捕获阶段触发
});
 
// 移除监听(需要同一个函数引用,匿名函数无法移除)
const handler = (e) => console.log('clicked');
btn.addEventListener('click', handler);
btn.removeEventListener('click', handler);

事件对象的关键属性

btn.addEventListener('click', (e) => {
  e.target; // 实际触发事件的元素(点击的最深层元素)
  e.currentTarget; // 绑定监听器的元素(即 this)
  e.type; // 事件类型 'click'
  e.eventPhase; // 1=捕获, 2=目标, 3=冒泡
 
  e.preventDefault(); // 阻止默认行为(链接跳转、表单提交)
  e.stopPropagation(); // 阻止冒泡
  e.stopImmediatePropagation(); // 阻止冒泡 + 阻止同元素上的其他监听器
 
  // 鼠标事件
  e.clientX;
  e.clientY; // 相对视口
  e.pageX;
  e.pageY; // 相对文档(含滚动)
  e.offsetX;
  e.offsetY; // 相对目标元素
 
  // 键盘事件
  e.key; // 'Enter'、'a' 等
  e.code; // 'KeyA'、'Digit1'(物理按键位置)
  e.ctrlKey;
  e.shiftKey;
  e.altKey;
  e.metaKey;
});

e.targete.currentTarget 的不同在事件代理中非常关键——前者告诉你”谁被点了”,后者告诉你”监听器绑在谁身上”。

事件冒泡与阻止

事件从触发元素沿 DOM 树向上传播:

function bindEvent(elem, type, fn) {
  elem.addEventListener(type, fn);
}
 
const p1 = document.getElementById('p1');
const body = document.body;
 
bindEvent(p1, 'click', (e) => {
  e.stopPropagation(); // 阻止继续向上冒泡
  console.log('激活');
});
 
bindEvent(body, 'click', (e) => {
  // 点击 p1 时不会执行到这里
  console.log('取消');
});

stopPropagation() 会阻止事件代理,也会影响第三方脚本(如埋点、错误监控),避免滥用。

自定义事件

除了浏览器内置事件,也可以用 CustomEvent 创建自定义事件:

// 创建自定义事件
const event = new CustomEvent('my-event', {
  detail: { message: 'hello' },
  bubbles: true, // 是否冒泡
  cancelable: true, // 是否可取消
});
 
// 派发
element.dispatchEvent(event);
 
// 监听
element.addEventListener('my-event', (e) => {
  console.log(e.detail.message); // 'hello'
});

在 Component/Widget 设计中,自定义事件是实现组件间解耦通信的常用手段。Vue 的 $emit 和 React 的 props 回调本质也是类似的思路。

事件代理

把监听器绑定在父元素上,利用冒泡统一处理所有子元素的事件。核心依赖 e.targetElement.matches()

const div1 = document.getElementById('div1');
 
div1.addEventListener('click', (e) => {
  e.preventDefault();
  const target = e.target;
  if (target.nodeName === 'A') {
    alert(target.innerHTML);
  }
});

用 matches 精确匹配

nodeName === 'A' 只能匹配标签名,matches() 支持任意 CSS 选择器:

div1.addEventListener('click', (e) => {
  // 只处理带有 data-action 属性的 <a> 标签
  if (e.target.matches('a[data-action]')) {
    handleAction(e.target.dataset.action);
  }
  // 只处理 .btn-primary,包括其内部元素(需要向上查找)
  const btn = e.target.closest('.btn-primary');
  if (btn) {
    handleClick(btn);
  }
});

closest(selector) 从当前元素开始向上查找匹配的祖先元素(包含自身),对于”点击按钮内部任意位置都算点击按钮”的场景非常实用。

事件代理的利弊

优点:

  • 代码简洁:一行监听代替 N 次绑定
  • 动态友好:新增的子元素自动获得事件处理
  • 节省内存:1000 个按钮只需 1 个监听器

局限:

  • 不适合需要 stopPropagation 的场景(代理依赖冒泡)
  • 命中逻辑过于复杂时,if 嵌套难维护
  • 不冒泡的事件(如 focusscroll)无法代理——但可以用 focusin 替代

通用事件绑定函数

/**
 * 事件绑定,支持普通绑定和代理绑定
 * @param {Element} elem          绑定的元素
 * @param {string}  type          事件类型
 * @param {string}  [selector]    代理模式下的子元素 CSS 选择器
 * @param {Function} fn           回调函数
 */
function bindEvent(elem, type, selector, fn) {
  if (fn == null) {
    fn = selector;
    selector = null;
  }
 
  elem.addEventListener(type, (event) => {
    const target = event.target;
    if (selector) {
      // 代理模式:检查触发元素是否匹配选择器
      if (target.matches(selector)) {
        fn.call(target, event);
      }
    } else {
      // 普通模式
      fn.call(target, event);
    }
  });
}
 
// 普通绑定
bindEvent(div1, 'click', function (e) {
  e.preventDefault();
  alert(this.innerHTML);
});
 
// 代理绑定
bindEvent(div1, 'click', 'a', function (e) {
  e.preventDefault();
  alert(this.innerHTML); // this 指向被点击的 <a>
});

事件委托更健壮的写法

上面的 matches 方案有一个局限:如果点击的是 <a> 内部的 <span>e.target<span> 而不是 <a>,代理就会失效。可以用 closest 修复:

elem.addEventListener(type, (event) => {
  const target = event.target.closest(selector);
  if (target && elem.contains(target)) {
    fn.call(target, event);
  }
});

closest 从触发元素开始向上查找,直到找到匹配 selector 的元素。elem.contains(target) 确保目标确实在代理范围内(而不是冒泡到了外面)。

相关笔记