从 DOM0 到 DOM2,全面掌握 JavaScript 事件流与事件机制

183 阅读11分钟

JavaScript 中的事件系统不仅仅是简单的点击响应,它背后有完整的“事件流机制”支撑,包括捕获、目标、冒泡三个阶段,以及 DOM0、DOM2 不同绑定方式的演化过程。本文将系统讲解 JS 事件机制核心原理及实践技巧,让你从入门到深入彻底掌握。

📌 一、DOM 是什么?

DOM(Document Object Model,文档对象模型)是浏览器把 HTML 或 XML 文档结构化成对象的方式,并提供了一套 API 让你用 JavaScript 去操作这些对象,从而动态控制网页内容、结构和样式。

一句话解释:

DOM 是 JavaScript 操作网页的“桥梁” 。它把页面转换成一棵“节点树”,让你可以像操作对象一样控制网页

二、DOM0 事件模型(非标准、最早期)

DOM0(或称为传统事件模型)是 JavaScript 最早的事件处理机制,是 DOM 事件模型的第一代实现方式,它广泛应用于早期的浏览器(如 IE4、Netscape Navigator)。尽管后续出现了更先进的事件模型(如 DOM2),但 DOM0 事件模型因其简单性和广泛兼容性,仍然常用于基础学习和某些轻量场景。 通过 DOM 元素的 事件属性(如 onclickonmouseover)直接绑定一个函数作为事件处理器。

<button id="btn">点击我</button>
<script>
  const btn = document.getElementById("btn");
  btn.onclick = function () {
    alert("按钮被点击了!");
  };
</script>

你会发现DOM0事件机制是没有我们现代的事件监听器addEventListener的,它的方式是内联事件绑定(如html标签上直接写onClick等) 这种方式会带来一些问题:

  • HTML 与 JavaScript 高度耦合,代码结构不清晰;
  • 只能绑定一个事件处理函数,后绑定的会覆盖之前的;
  • 动态添加和解绑事件困难,扩展性差。

因此,DOM0 事件模型逐渐被更先进的 DOM2 事件模型取代。

三、DOM2 事件模型

DOM2(Document Object Model Level 2)是 W3C 在早期 DOM0 基础上扩展和标准化的一套事件处理机制。它在 DOM0 的基础上,提供了更强大、灵活的事件系统,并首次引入了完整的“事件流”概念,包括捕获阶段冒泡阶段,让开发者可以更细致地控制事件的传递过程。

简单来说,DOM2 让事件不仅仅是在被点击的元素上处理,还可以在事件“传递的路径上”进行拦截和处理。

如何使用 DOM2 来添加事件?

在 DOM2 模型中,我们使用 addEventListener 方法来给元素添加事件监听器。这个方法接受三个参数:

  1. 第一个是事件类型,比如 'click''mouseover''keydown' 等;
  2. 第二个是事件处理函数,即你希望在事件触发时执行的代码;
  3. 第三个是一个布尔值,表示事件是在捕获阶段触发(true),还是在冒泡阶段触发(false,默认)。 举个例子,如果我们想要在一个按钮被点击时弹出提示框,可以这样写:
const btn = document.getElementById("myButton");
btn.addEventListener("click", function () {
  alert("按钮被点击了!");
}, false);

这个例子中,事件是在冒泡阶段被触发的,因为第三个参数是 false。如果你把它改成 true,那事件会在捕获阶段处理。

为什么没有 DOM1 事件?

DOM Level 1(DOM1)主要定义了文档结构(如节点树、操作方法),并没有标准化事件模型,因此事件机制的标准化从 DOM2 开始。

四、什么是事件流?

事件流描述了一个事件从页面最外层传递到目标元素再返回的过程。这个过程分为三个阶段:

  1. 捕获阶段:事件从 window 向内层元素传递,依次经过每个父元素,直到目标元素前为止;
  2. 目标阶段:事件到达目标元素本身;
  3. 冒泡阶段:事件从目标元素开始,沿着 DOM 树向上传播到 window

举个例子,假设你点击了一个嵌套在很多父元素中的按钮:

  • 如果使用捕获阶段监听,事件会先从 html -> body -> 父容器 -> 子容器 一路传到按钮;
  • 如果使用冒泡阶段监听,事件会从按钮 -> 子容器 -> 父容器 -> body -> html 一路往回传。

这就是事件流的全流程。

捕获阶段

什么是捕获阶段?

捕获阶段指事件从页面根节点(windowdocument)开始,沿着 DOM 树向事件目标元素传播的过程。事件在捕获阶段经过的每一个节点都有机会监听并响应该事件(前提是监听器是用捕获模式注册的)。

捕获阶段的过程

ChatGPT Image 2025年7月9日 00_53_45.png

举个例子:

假设有如下 DOM 结构:

<body>
  <div id="parent">
    <button id="child">点击我</button>
  </div>
</body>

事件流传播顺序:

windowdocumenthtmlbodydiv#parentbutton#child(目标)
  • 事件从 window 开始捕获
  • 依次传递到 documenthtmlbodydiv#parent
  • 最后到达目标元素 button#child

目标阶段

什么是目标阶段?

目标阶段指的是事件到达 事件目标元素 (触发事件的元素)时的阶段。

在这个阶段,浏览器会调用目标元素上所有注册的事件监听器,无论这些监听器是捕获阶段注册还是冒泡阶段注册的。

目标阶段的特点

  • 事件处理函数会在这里被触发。
  • 捕获阶段冒泡阶段注册的监听器都会被调用,只是调用时机不同。
  • 事件先执行目标元素上注册的捕获监听器,然后执行目标元素上注册的冒泡监听器。
  • 事件处理顺序如下:
(捕获阶段从上层节点到目标元素)→目标元素捕获阶段监听器 → 目标元素冒泡阶段监听器 →(冒泡阶段从目标元素到上层节点)

例子说明

<div id="parent">
  <button id="child">按钮</button>
</div>
const parent = document.getElementById('parent');
const child = document.getElementById('child');

parent.addEventListener('click', () => console.log('parent 捕获'), { capture: true });
parent.addEventListener('click', () => console.log('parent 冒泡'));

child.addEventListener('click', () => console.log('child 捕获'), { capture: true });
child.addEventListener('click', () => console.log('child 冒泡'));

点击按钮时,执行顺序是:

  1. parent 捕获(捕获阶段,父元素)
  2. child 捕获(目标阶段,目标元素的捕获监听器)
  3. child 冒泡(目标阶段,目标元素的冒泡监听器)
  4. parent 冒泡(冒泡阶段,父元素)

目标阶段的重要性

  • 目标阶段是事件处理的核心阶段。
  • 事件的默认行为通常在目标阶段处理。
  • 开发者在目标元素上处理事件的主要业务逻辑。

冒泡阶段

什么是冒泡阶段?

冒泡阶段是事件流的最后一个阶段,指事件从事件目标元素开始,沿着 DOM 树向其父元素、祖先元素逐层向上传播,直到 window 对象。 在这个阶段,浏览器会调用注册在这些祖先节点上的冒泡事件监听器。

冒泡阶段的传播顺序

举个例子:

<body>
  <div id="parent">
    <button id="child">点击我</button>
  </div>
</body>

事件流冒泡顺序:

button#child(目标) → div#parentbodyhtmldocumentwindow

当点击按钮时,事件首先在目标元素处理(目标阶段),随后进入冒泡阶段,从按钮开始逐层向上传播,每个节点的冒泡监听器依次执行。

五、什么是事件监听器?

事件监听器是开发者为某个 DOM 元素注册的函数,用来响应该元素上发生的特定事件。 当事件流经过元素时,若该元素绑定了对应事件类型的监听器,浏览器会调用这个监听器函数。

事件监听器的注册

JavaScript 中,最常用的注册监听器的方法是:

element.addEventListener(type, listener, options);
  • type:事件类型,如 'click''keydown'
  • listener:回调函数,事件触发时调用。
  • options:控制监听器行为的配置。

监听器的触发阶段

监听器可以在事件流的不同阶段触发:

  • 捕获阶段监听器
    通过 { capture: true } 注册,事件捕获阶段触发。
  • 目标阶段监听器
    事件流到达目标元素时,目标元素上注册的捕获和冒泡监听器都会被调用。
  • 冒泡阶段监听器
    默认注册(或 { capture: false })的监听器,在事件冒泡阶段触发。

监听器执行顺序

假设事件流经过以下顺序:

windowdocumenthtmlbodydiv#parentbutton#child(目标)
  • 在捕获阶段,监听器按从外到内的顺序执行。
  • 到达目标阶段时,目标元素上的捕获监听器先执行,冒泡监听器后执行。
  • 在冒泡阶段,监听器按从内到外的顺序执行。

监听器函数的参数

监听器函数会自动接收一个事件对象(event),包含事件相关信息:

  • event.type:事件类型。
  • event.target:事件最初触发的元素。
  • event.currentTarget:当前正在处理事件的元素(即监听器绑定的元素)。
  • event.preventDefault():阻止事件默认行为。
  • event.stopPropagation():阻止事件继续传播。
  • 其他属性和方法,视事件类型而定。

六、什么是事件阻止?

事件阻止包括两种主要操作:

  1. 阻止事件的默认行为
    比如阻止链接跳转、表单提交、右键菜单弹出等默认动作。
  2. 阻止事件继续传播
    阻止事件在事件流中继续往下或往上传递,避免触发其他监听器。

###如何阻止默认行为?

使用事件对象的 event.preventDefault() 方法。

示例:

const link = document.querySelector('a');

link.addEventListener('click', event => {
  event.preventDefault();  // 阻止跳转
  alert('链接默认行为被阻止了');
});

如何阻止事件传播?

事件传播包括捕获和冒泡两个阶段,阻止传播意味着事件不会传递给其他监听器。

1. event.stopPropagation()

  • 阻止事件在当前阶段之后继续传播(无论捕获还是冒泡)。
  • 但不会阻止同一元素上其他同类型监听器执行。
parent.addEventListener('click', () => {
  console.log('父元素监听器');
});

child.addEventListener('click', event => {
  event.stopPropagation();
  console.log('子元素监听器,阻止事件传播');
});

点击子元素时,只会打印子元素监听器。


2. event.stopImmediatePropagation()

  • 除了阻止事件继续传播,还会阻止当前元素上其他同类型监听器执行。
element.addEventListener('click', () => {
  console.log('监听器1');
});

element.addEventListener('click', event => {
  event.stopImmediatePropagation();
  console.log('监听器2,阻止立即传播');
});

element.addEventListener('click', () => {
  console.log('监听器3');
});

点击元素时,只会打印“监听器2”,监听器1和监听器3都不会执行。


阻止的实际用途

  • 避免事件影响其他元素,减少副作用。
  • 实现自定义控件的事件逻辑。
  • 优化性能,避免不必要的事件处理。

七、什么是事件委托?

事件委托是一种利用事件冒泡机制,将多个子元素的事件处理器,统一绑定到它们的父元素上,由父元素统一管理和处理子元素的事件。 这样可以减少内存消耗,避免给每个子元素都绑定监听器,提高性能和代码维护性。

原理基础

  • 浏览器事件触发后,会冒泡到祖先元素。
  • 父元素通过监听子元素的冒泡事件,判断事件来源(event.target),从而知道哪个子元素触发了事件。
  • 父元素根据事件目标执行相应逻辑。

传统做法 vs 事件委托

传统做法:

给每个子元素绑定监听器:

const items = document.querySelectorAll('li');
items.forEach(item => {
  item.addEventListener('click', () => {
    console.log('点击了', item.textContent);
  });
});

缺点:当子元素很多,绑定大量监听器影响性能;动态添加元素时需额外绑定。


事件委托:

给父元素绑定监听器,利用冒泡处理所有子元素事件:

const list = document.getElementById('list');

list.addEventListener('click', event => {
  if (event.target && event.target.nodeName === 'LI') {
    console.log('点击了', event.target.textContent);
  }
});

事件委托的优点

  • 节省内存:只绑定一个监听器。
  • 动态元素支持:动态添加的子元素无需额外绑定监听器。
  • 易于维护:事件逻辑集中管理。

注意事项

  • 确认事件会冒泡,否则委托无效(如 focusblur 不冒泡)。
  • 事件目标(event.target)可能是子孙元素,需判断或使用 event.currentTarget 区分。
  • 复杂场景下可能需结合 event.target.closest() 等方法定位具体目标。

简单示例

HTML:

<ul id="list">
  <li>项1</li>
  <li>项2</li>
  <li>项3</li>
</ul>

JS:

const list = document.getElementById('list');

list.addEventListener('click', event => {
  const li = event.target.closest('li');
  if (li && list.contains(li)) {
    console.log('点击了', li.textContent);
  }
});

八、总结

概念要点
事件流捕获 → 目标 → 冒泡
DOM0最早期绑定方式,简单但不能多次绑定
DOM2标准方式,支持多处理器、阶段控制
捕获 vs 冒泡捕获优先执行,冒泡是默认
事件委托利用冒泡,在父元素统一处理子元素事件
常用控制stopPropagation()preventDefault()

如果你掌握了以上内容,JS 事件机制就不再神秘,能够灵活应对各种复杂事件场景。
欢迎收藏与分享,助力你的前端成长之路!