深入理解 React 事件机制与 DOM 事件系统

352 阅读13分钟

深入理解 React 事件机制与 DOM 事件系统

要真正理解 React 如何处理事件,我们首先需要掌握浏览器原生 DOM 事件系统的工作原理。

一、DOM 事件系统基础

1. DOM 事件级别

DOM API 的不同级别定义了事件的处理方式。

  • DOM Level 0 事件:这是最早、最直接的事件处理方式。你直接在 HTML 元素的事件属性上赋值一个事件处理函数。

    <buttoon onclick="console.log('clicked')">click</button>
    <button onclick="console.log('点击了!')">点击我</button>
    

    虽然简单,但这种方法有局限性,比如每个事件类型和元素只能绑定一个处理函数。

  • DOM Level 1:此级别主要关注核心 DOM 结构,没有对事件系统引入重大改变。

  • DOM Level 2 事件:此级别引入了 addEventListenerremoveEventListener 方法,提供了更强大和灵活的事件处理能力。

    element.addEventListener(eventType, listener, options)

    • eventType:一个字符串,表示要监听的事件类型(例如:'click''mouseover')。

    • listener:事件发生时要调用的函数。

    • options:一个可选对象,可以包含:

      • capture:一个布尔值,指示监听器是否应在捕获阶段被调用(默认为 false,即在冒泡阶段)。
      • once:一个布尔值,指示监听器最多只会被调用一次。
      • passive:一个布尔值,指示监听器永远不会调用 preventDefault()

    JavaScript

    const element = document.getElementById('myButton');
    element.addEventListener('click', function() {
      console.log('使用 addEventListener 点击了!');
    });
    

2. DOM 事件流

当一个事件在 DOM 中的某个元素上发生时,它并不仅仅只在这个元素上发生。相反,它会经历一个特定的生命周期,称为事件流,包含三个阶段:

  1. 捕获阶段:事件从 window 对象开始,向下传播到目标元素。在此阶段注册的事件处理函数(通过 useCapture: truecapture: true 选项)会被触发。
  2. 目标阶段:事件到达实际触发它的目标元素。直接附加在目标元素上的任何监听器都会被执行。
  3. 冒泡阶段:事件随后从目标元素向上回溯到 window 对象。在此阶段注册的事件处理函数(没有 useCapturecapture: false 选项,这是默认值)会被触发。

JavaScript

// 事件监听器在两个阶段的示例:
const grandParent = document.getElementById('grandparent');
const parent = document.getElementById('parent');
const child = document.getElementById('child');

// 捕获阶段(第三个参数为 true)
grandParent.addEventListener('click', () => console.log('祖父元素 (捕获)'), true);
parent.addEventListener('click', () => console.log('父元素 (捕获)'), true);
child.addEventListener('click', () => console.log('子元素 (捕获)'), true);

// 冒泡阶段(第三个参数为 false 或省略)
grandParent.addEventListener('click', () => console.log('祖父元素 (冒泡)'), false);
parent.addEventListener('click', () => console.log('父元素 (冒泡)'), false);
child.addEventListener('click', () => console.log('子元素 (冒泡)'), false);

// 如果你点击 'child',大致输出会是:
// 祖父元素 (捕获)
// 父元素 (捕获)
// 子元素 (捕获)
// 子元素 (冒泡) // 目标阶段监听器在冒泡前执行,但概念上属于目标阶段
// 父元素 (冒泡)
// 祖父元素 (冒泡)

二、React 合成事件系统

React 实现了一套自己的事件系统,称为“合成事件”(SyntheticEvent)。该系统封装了浏览器的原生事件,提供了跨不同环境的一致且优化的体验。

1. 为什么需要合成事件?

React 的合成事件系统存在有几个关键原因:

  1. 跨浏览器兼容性:不同浏览器在原生事件对象和行为上存在细微差异。React 的合成事件系统规范化了这些差异,提供了一个跨所有支持浏览器都能可靠工作的统一 API。这意味着你无需编写针对特定浏览器的事件处理逻辑。
  2. 性能优化:React 利用事件委托(也称为事件代理)来最小化实际附加到 DOM 上的事件监听器数量。React 不会为每个 DOM 元素都附加一个监听器,而是在 DOM 树中较高层级(或你的应用根部)附加一个单一监听器。这显著减少了内存消耗和设置开销,尤其适用于拥有许多交互元素的应用程序。
  3. 统一管理:合成事件允许 React 在其自身的**协调(reconciliation)**过程中高效地管理事件。这使得诸如批量更新多个事件等功能成为可能,从而带来更好的性能。

2. 合成事件的特点

  • 事件委托

    • React 16 及更早版本:React 几乎将所有事件都委托到了 document 对象上。
    • React 17 及更新版本:React 改变了其事件委托策略。事件现在直接附加到你的 React 应用程序挂载的根 DOM 容器上(例如,ReactDOM.createRoot(rootElement))。这一改变使得在单个页面中嵌入多个 React 应用,或将 React 与非 React 代码集成变得更加容易,因为一个 React 树的事件不会无意中影响到另一个。
  • 事件池(历史特性) :在 React 16 及更早版本中,合成事件对象为了性能被复用(池化) 。在事件回调执行后,合成事件对象的属性会被清空。这意味着如果你需要在异步操作(例如 setTimeout)中访问事件属性,你必须显式地调用 e.persist()

  • 无事件池(React 17+) :React 17 完全移除了事件池机制。合成事件对象不再被复用。这意味着你可以安全地在异步操作中访问事件属性,而无需调用 e.persist()

  • 访问原生事件:每个合成事件对象都有一个 nativeEvent 属性,你可以通过它访问底层的浏览器原生 DOM 事件对象。如果你需要访问合成事件未暴露的特定浏览器属性或方法,这会很有用。

    JavaScript

    function handleClick(e) {
      console.log('合成事件:', e);
      console.log('原生事件:', e.nativeEvent); // 访问底层的 DOM 事件
      e.preventDefault(); // 阻止浏览器默认行为(例如,表单提交)
    }
    
    // 在 JSX 中:
    // <button onClick={handleClick}>点击我</button>
    

三、事件委托(事件代理)

事件委托是一种强大的模式,无论是原生 DOM 还是 React 的合成事件系统,都利用它来提高效率。

1. 原理

事件委托利用了事件冒泡阶段的特性。它不是为许多子元素单独附加事件监听器,而是将一个单一的事件监听器附加到它们的共同父元素上。当事件在子元素上触发时,它会冒泡到父元素。父元素上的单一监听器然后处理该事件,并识别出实际触发事件的目标子元素。

JavaScript

function List() {
  const handleClick = (e) => {
    // e.target 指的是最初触发事件的元素 (即 <li>)
    // e.currentTarget 指的是事件监听器附加到的元素 (即 <ul>)
    if (e.target.tagName === 'LI') { // 确保点击发生在 <li> 上
      console.log('你点击了:', e.target.textContent);
    }
  };

  return (
    <ul onClick={handleClick}> {/* 监听器在父元素 <ul> 上 */}
      <li>项目 1</li>
      <li>项目 2</li>
      <li>项目 3</li>
    </ul>
  );
}

2. 优势

  1. 减少内存消耗:通过仅向父元素附加一个监听器,而不是为每个子元素附加多个监听器,可以显著减少内存占用。这对于大型列表或表格尤其有利。
  2. 支持动态元素:当新的子元素被添加到 DOM 中时(例如,通过用户交互或数据获取),它们会自动拥有事件处理能力,无需显式附加新的监听器。父元素的监听器会在它们冒泡时捕获到事件。
  3. 性能优化:更少的事件监听器意味着浏览器需要管理的开销更小。这会带来更好的整体应用程序性能,特别是在频繁变化或非常长的列表中。

3. 注意事项

  • 阻止冒泡:如果子元素在原生 DOM 事件上调用了 e.stopPropagation(),它将阻止事件冒泡到委托的父监听器。这会有效地“破坏”该事件的事件委托。

  • 识别目标:正确识别最初触发事件的元素至关重要。

    • e.target:指代事件最初发生的实际 DOM 元素(DOM 树中被点击的最底层元素)。
    • e.currentTarget:指代事件监听器附加到的 DOM 元素(由于冒泡而接收到事件的元素)。

四、React 事件与原生事件的执行顺序

理解 React 合成事件和原生 DOM 事件的执行顺序对于调试和预测行为至关重要,尤其是在混合使用两者时。

React 16 及更早版本:

当事件发生时,原生 DOM 事件通常会完成其完整的捕获-目标-冒泡周期,然后 React 的合成事件处理函数才会被调用。

  1. 原生 DOM 捕获阶段(从 window 到目标)
  2. 原生 DOM 目标阶段(在目标元素上)
  3. 原生 DOM 冒泡阶段(从目标到 document
  4. React 合成事件(在原生事件冒泡到 document 监听器后被调用,由 React 处理,然后按照 React 组件树的顺序传递给你的组件处理函数,这通常模拟冒泡顺序)

React 17 及更新版本:

React 17 将事件委托点从 document 更改为根容器。这一细微但重要的改变影响了当存在原生监听器时的执行顺序。

  1. 原生 DOM 捕获阶段(从 window 到目标)
  2. React 合成捕获事件(如果使用了任何 onCapture 属性,这些会在原生冒泡之前,但原生捕获之后运行)
  3. 原生 DOM 目标阶段(在目标元素上)
  4. React 合成冒泡事件(这些会在原生冒泡之前运行)
  5. 原生 DOM 冒泡阶段(从目标到 window

React 17+ 简化执行顺序(针对包含原生和 React 处理程序的 React 元素点击)

假设你有一个带有原生 click 监听器的 div,并且它内部有一个带有 React onClick 处理函数的 button

  • div 的祖先元素上的原生捕获监听器(例如,documentbody
  • React 内部的捕获阶段处理函数(附加到根容器)
  • button(目标元素)上的原生监听器(如果有的话)
  • React 内部的冒泡阶段处理函数(附加到根容器),然后将合成事件分派给你的 React 组件的 onClick 处理函数。
  • div 及其祖先元素上的原生冒泡监听器(例如,documentbody

这意味着 React 的合成事件现在在直接附加到 React 根之外的元素上的原生 DOM 冒泡事件之前执行。这可能会引起混淆,但这是为了改善集成而有意为之的设计。


五、常见问题与解决方案

1. 阻止事件冒泡

有时,你需要阻止事件在 DOM 树中继续传播。

JavaScript

function handleClick(e) {
  e.stopPropagation(); // 阻止合成事件在 React 层次结构中冒泡。
                       // 在 React 17+ 中,它不能在合成事件运行后阻止原生 DOM 冒泡。

  // 要阻止 *原生* DOM 事件进一步传播(包括冒泡和立即传播):
  e.nativeEvent.stopPropagation();        // 阻止原生事件在 DOM 树中冒泡
  e.nativeEvent.stopImmediatePropagation(); // 阻止原生冒泡 *并且* 阻止在 *同一元素* 或 *更高层级* 的任何其他原生监听器被调用。
                                           // 如果你想确保没有其他原生监听器对该事件作出反应,请使用此方法。
}

关于 stopPropagation() 的重要说明:

  • React 中的 e.stopPropagation() 仅阻止合成事件在 React 组件层次结构中冒泡。
  • 在 React 17+ 中,因为 React 委托的事件在原生冒泡之前运行,所以合成事件上的 e.stopPropagation() 将阻止后续的 React 处理程序运行,但除非同时调用 e.nativeEvent.stopPropagation()e.nativeEvent.stopImmediatePropagation(),否则原生事件仍将继续在原生 DOM 树中冒泡

2. 事件池(React 17 之前)

如前所述,在 React 16 及更早版本中,合成事件对象是池化的。这意味着在你的事件处理程序执行后,它们的属性会被清空。如果你需要在异步操作(例如在 setTimeout 内部)中访问事件属性,你必须显式地“持久化”事件:

JavaScript

// React 16 及更早版本:
function handleClick(e) {
  e.persist(); // 确保事件对象及其属性在当前事件处理程序结束后不会被清空。
  setTimeout(() => {
    console.log(e.target); // 如果没有 e.persist(),这里会是 null/undefined
  }, 100);
}

React 17+ 建议:

随着 React 17 中移除了事件池,e.persist() 不再需要。你可以安全地在异步操作中访问事件属性,无需任何特殊处理。

3. 混合使用 React 和原生事件监听器

通常建议使用 React 的合成事件系统以保持一致性和性能。然而,在某些情况下,你可能需要直接与原生 DOM 事件交互(例如,与直接操作 DOM 的第三方库集成,或监听 window 上的 scroll 等事件)。

混合使用时,请注意执行顺序,尤其是在 React 17+ 中。使用 useEffect 来管理原生事件监听器,以确保正确清理。

JavaScript

import React, { useEffect } from 'react';

function MyComponent() {
  useEffect(() => {
    const el = document.getElementById('my-btn');
    const handler = () => console.log('原生事件监听器触发了!');

    // 附加原生事件监听器
    if (el) { // 在附加之前总是检查元素是否存在
      el.addEventListener('click', handler);
    }

    // 清理函数:当组件卸载时移除原生事件监听器
    return () => {
      if (el) {
        el.removeEventListener('click', handler);
      }
    };
  }, []); // 空依赖数组意味着此副作用在挂载时运行一次,并在卸载时清理

  return (
    <button id="my-btn" onClick={() => console.log('React 合成事件触发了!')}>
      点击我
    </button>
  );
}

六、最佳实践

  1. 优先使用 React 合成事件:对于 React 组件中的大多数交互元素,请使用 React 的 onClickonChangeonSubmit 等。这利用了 React 的优化、一致性和跨浏览器兼容性。
  2. 利用事件委托优化性能:当处理许多相似的交互元素(例如,长列表或网格中的项目)时,通过将单个事件监听器附加到 React 中的共同父元素来应用事件委托。然后,使用 e.target 来识别是哪个子元素触发了事件。
  3. 谨慎使用 stopPropagation:仅在绝对必要时使用 e.stopPropagation() 来防止事件影响父组件。过度使用会使应用程序的事件流难以理解和调试。请记住它在 React 17+ 中的特定行为。
  4. 异步访问(React 17+) :在 React 17 及更高版本中,你不再需要 e.persist() 来异步访问事件属性。你可以安全地在 setTimeoutPromise 回调中访问 e.targete.currentTarget 等。
  5. 清理原生监听器:如果你必须使用原生的 addEventListener,请务必在 useEffect Hook 的清理函数中将其与 removeEventListener 配对,以防止内存泄漏。
  6. 理解执行顺序:了解 React 16/更早版本和 React 17+ 之间不同的事件执行顺序。这些知识对于调试涉及 React 和原生事件处理程序的复杂交互至关重要。

总结

React 的事件系统是建立在原生 DOM 事件之上的高级抽象。它在跨浏览器兼容性、通过事件委托实现的性能以及 React 生态系统内的统一事件管理方面提供了显著优势。

通过理解其底层原理,包括 DOM 事件流、合成事件背后的原因以及事件委托和传播的细微差别,你可以编写更高效、更易维护、更健壮的 React 应用程序。随着 React 版本的演进,持续关注诸如 React 17 中事件委托的转变等变化,是做出明智技术决策和避免常见陷阱的关键。