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

React篇 | 合成事件

前言

在 React 中写一个 onClick,你可能觉得和原生的 addEventListener('click', ...) 没什么区别。但面试官一追问,你就会发现水很深:

  • React 的 onClick 实际上绑定在哪个元素上?
  • 合成事件和原生事件的执行顺序是什么?
  • e.stopPropagation() 在 React 中能阻止原生事件吗?
  • 事件池(Event Pooling)是什么?React 17 为什么把它移除了?
  • e.nativeEvente.persist() 分别是干嘛的?

这些问题考察的是你对 React 事件系统底层机制的理解。知道 “React 用了事件委托” 只是入门,真正有深度的回答需要理解为什么这样设计React 17 做了什么重大改变、以及合成事件与原生事件的交互行为

本章就来把合成事件彻底讲透。


诊断自测

Q1:下面的代码中,点击按钮后控制台的输出顺序是什么?

function App() {
  const btnRef = useRef(null);

  useEffect(() => {
    btnRef.current.addEventListener('click', () => {
      console.log('native');
    });
    document.addEventListener('click', () => {
      console.log('document native');
    });
  }, []);

  return (
    <div onClick={() => console.log('react div')}>
      <button
        ref={btnRef}
        onClick={() => console.log('react button')}
      >
        Click
      </button>
    </div>
  );
}
点击查看答案

在 React 17+ 中,输出顺序是:

native
react button
react div
document native

原因:

  1. 原生事件在目标元素上的监听器先执行 → native
  2. 事件冒泡到 root(React 17+ 事件委托的位置),React 处理合成事件,按 React 组件树从内到外冒泡 → react buttonreact div
  3. 事件继续冒泡到 document,触发 document 上的原生监听器 → document native

Q2:React 17 之前和之后,事件委托的目标元素有什么变化?为什么要改?

点击查看答案
  • React 16 及之前:事件委托在 document
  • React 17 及之后:事件委托在 React 挂载的根 DOM 容器root)上

改变的原因主要有两个:

  1. 微前端场景:当页面上有多个 React 应用(或 React + 其他框架混合)时,如果都委托到 document,事件处理会互相干扰。委托到各自的 root 容器,就实现了事件隔离
  2. 渐进升级:一个页面可以同时运行不同版本的 React,每个版本管理自己 root 下的事件

Q3:在 React 中调用 e.stopPropagation() 和原生的 e.stopPropagation() 效果一样吗?

点击查看答案

不完全一样。React 的 e.stopPropagation() 阻止的是React 合成事件的冒泡(在 React 组件树中的冒泡),不会阻止原生事件的冒泡。反过来,在原生事件中调用 stopPropagation() 可能会阻止事件冒泡到 React 的 root 容器,导致 React 的合成事件根本不触发

这种不对称性是理解合成事件的关键。


一、什么是合成事件(SyntheticEvent)

1.1 定义

合成事件是 React 对浏览器原生事件的跨浏览器包装。当你在 React 中写 onClickonChange 等事件处理器时,你拿到的 e 不是原生的 Event 对象,而是 React 创建的 SyntheticEvent 实例。

function Button() {
  const handleClick = (e) => {
    console.log(e);                // SyntheticBaseEvent
    console.log(e.nativeEvent);    // 原生的 MouseEvent
    console.log(e.constructor.name); // SyntheticBaseEvent
  };

  return <button onClick={handleClick}>Click</button>;
}

1.2 为什么要封装一层?

React 设计合成事件系统主要有三个目的:

跨浏览器一致性

不同浏览器的事件对象有差异(比如 IE 的 event.srcElement vs 标准的 event.target)。合成事件屏蔽了这些差异,提供统一的接口。虽然现代浏览器的差异已经很小了,但这种封装仍然有价值。

事件委托(Event Delegation)

React 不会在每个 DOM 元素上绑定事件监听器。相反,它把所有事件监听器委托到一个统一的位置(React 17+ 是 root 容器),通过事件冒泡来处理。这大大减少了内存开销和 DOM 操作。

与 React 渲染系统集成

合成事件与 React 的批量更新(batching)、优先级调度等机制深度集成。在合成事件的处理器中调用多次 setState,React 会自动合并为一次渲染。


二、事件委托:React 17 的重大变化

2.1 React 16:委托到 document

在 React 16 及之前,所有事件都委托到 document 上。

用户点击 <button>
  → 原生事件在 button 上触发
  → 冒泡到 div、body、html
  → 冒泡到 document
  → React 在 document 上的监听器被触发
  → React 根据事件的 target 查找对应的 Fiber 节点
  → 沿 Fiber 树从内到外依次执行合成事件处理器

2.2 React 17+:委托到 root 容器

React 17 把事件委托从 document 改到了 root 容器(即 createRootrender 挂载的那个 DOM 元素)。

// React 16
ReactDOM.render(<App />, document.getElementById('root'));
// 事件委托到 document

// React 17+
ReactDOM.createRoot(document.getElementById('root')).render(<App />);
// 事件委托到 #root 元素
用户点击 <button>
  → 原生事件在 button 上触发
  → 冒泡到 div
  → 冒泡到 #root
  → React 在 #root 上的监听器被触发
  → React 处理合成事件
  → 事件继续冒泡到 body、html、document

2.3 为什么要改?

场景一:多 React 实例共存

<div id="app1"><!-- React App 1 --></div>
<div id="app2"><!-- React App 2 --></div>

如果都委托到 document,App1 中 stopPropagation() 会阻止 App2 的事件处理。委托到各自的 root 后,两个应用的事件系统完全独立。

场景二:React + 其他框架混合

<div id="react-root"><!-- React --></div>
<div id="vue-root"><!-- Vue --></div>

React 16 委托到 document 时,可能和 Vue 的事件处理互相干扰。React 17+ 只管自己 root 下的事件。

场景三:渐进式升级

在大型项目中,你可以先把一部分页面升级到新版 React,其他部分仍用旧版。每个版本的 React 管理自己 root 下的事件,互不影响。


三、事件池(Event Pooling)—— React 17 已移除

3.1 React 16 的事件池机制

在 React 16 及之前,合成事件对象会被复用。React 维护一个事件对象池,事件处理器执行完后,合成事件的所有属性会被清空(设为 null),对象被放回池中等待下次使用。

// React 16 中的行为
function handleClick(e) {
  console.log(e.type);     // 'click' ✅

  setTimeout(() => {
    console.log(e.type);   // null ❌ 事件对象已被回收
  }, 0);
}

这意味着:在异步回调中访问合成事件的属性,会得到 null。

3.2 e.persist():阻止回收

为了在异步场景中使用事件对象,React 16 提供了 e.persist() 方法:

// React 16
function handleClick(e) {
  e.persist(); // 把事件对象从池中"拿出来",不再回收

  setTimeout(() => {
    console.log(e.type); // 'click' ✅ 现在可以了
  }, 0);
}

3.3 React 17 移除了事件池

React 17 完全移除了事件池机制。合成事件对象不再被复用和清空:

// React 17+ 中的行为
function handleClick(e) {
  console.log(e.type);     // 'click' ✅

  setTimeout(() => {
    console.log(e.type);   // 'click' ✅ 不再被清空
  }, 0);
}

移除的原因:

  • 事件池的性能优化在现代浏览器中几乎没有可测量的收益
  • 它给开发者带来了大量困惑和 Bug(忘记 persist() 导致异步访问属性为 null)
  • 代码复杂度增加

面试中的考点: 面试官可能问你 e.persist() 的作用。你需要知道它是 React 16 的概念,React 17 已经不需要了。如果面试官追问”为什么移除”,重点是性能收益微乎其微但开发者困惑很大。


四、合成事件 vs 原生事件的执行顺序

这是面试中出场率极高的考点。理解执行顺序,需要搞清楚两个关键点:

  1. React 的合成事件依赖原生事件冒泡到 root 容器后才触发
  2. 原生事件的监听器按照 DOM 冒泡顺序正常执行

4.1 React 17+ 的执行顺序

function App() {
  const childRef = useRef(null);
  const parentRef = useRef(null);

  useEffect(() => {
    // 原生事件
    childRef.current.addEventListener('click', () => {
      console.log('1: child native');
    });
    parentRef.current.addEventListener('click', () => {
      console.log('2: parent native');
    });
    document.addEventListener('click', () => {
      console.log('5: document native');
    });
  }, []);

  return (
    <div
      ref={parentRef}
      onClick={() => console.log('4: parent react')}
    >
      <button
        ref={childRef}
        onClick={() => console.log('3: child react')}
      >
        Click
      </button>
    </div>
  );
}

点击按钮后的输出顺序:

1: child native          ← 原生事件在目标元素上触发
2: parent native         ← 原生事件冒泡到父元素
3: child react           ← 冒泡到 root 后,React 处理合成事件(从内到外)
4: parent react
5: document native       ← 最后冒泡到 document

4.2 图示:事件流

点击 <button>

第一阶段:原生事件冒泡
  button (child native) → div (parent native) → #root

第二阶段:React 在 #root 上处理
  React 合成事件冒泡:child react → parent react

第三阶段:继续原生冒泡
  #root → body → html → document (document native)

4.3 stopPropagation 的交互

这里有几个非常容易混淆的行为:

情况一:React 中 stopPropagation

<button onClick={(e) => {
  e.stopPropagation();
  console.log('react button');
}}>

效果:阻止合成事件在 React 组件树中继续冒泡,父组件的 onClick 不会触发。但原生事件该怎么冒泡还怎么冒泡——因为原生事件早就冒泡完了(在 React 处理合成事件之前)。不过,document 上的原生事件会被阻止,因为 React 在 root 上调用了原生的 stopPropagation。

情况二:原生事件中 stopPropagation

useEffect(() => {
  childRef.current.addEventListener('click', (e) => {
    e.stopPropagation(); // 原生事件停止冒泡
  });
}, []);

效果:原生事件不再冒泡到 root 容器,React 的合成事件完全不会触发——因为 React 依赖原生事件冒泡到 root 来触发合成事件处理。

这是一个非常重要的面试考点:在原生事件中 stopPropagation 会”杀死”React 的合成事件。


五、e.nativeEvent 的用途

每个合成事件对象都有一个 nativeEvent 属性,指向底层的原生浏览器事件:

function handleClick(e) {
  // 合成事件对象
  console.log(e);              // SyntheticBaseEvent
  console.log(e.type);         // 'click'
  console.log(e.target);       // <button>

  // 原生事件对象
  console.log(e.nativeEvent);  // MouseEvent
  console.log(e.nativeEvent.composedPath()); // 事件路径
}

什么时候需要 nativeEvent?

大多数情况下你不需要它。但在以下场景可能用到:

  1. 判断事件来源e.nativeEvent.composedPath() 可以获取完整的事件冒泡路径,包括 Shadow DOM 中的元素
  2. 访问原生独有的属性:某些原生事件属性没有被 React 包装(如 InputEventinputType
  3. 与第三方库交互:某些库需要原生事件对象
function handleInput(e) {
  // React 的 onChange 不提供 inputType
  const inputType = e.nativeEvent.inputType;
  if (inputType === 'insertFromPaste') {
    console.log('用户粘贴了内容');
  }
}

六、在 React 中使用原生事件的注意事项

有时候你不得不在 React 组件中使用原生事件(比如需要 capture 阶段的精确控制、或者某些 React 不支持的事件类型)。但使用时有几个重要的注意事项。

6.1 必须手动清理

useEffect(() => {
  const handler = (e) => {
    console.log('native click');
  };

  // 添加原生事件
  document.addEventListener('click', handler);

  // ⚠️ 必须在清理函数中移除,否则会内存泄漏
  return () => {
    document.removeEventListener('click', handler);
  };
}, []);

React 的合成事件会自动管理绑定和解绑,但原生事件需要你自己负责。

6.2 注意执行顺序

前面讲过,原生事件在 React 合成事件之前执行。如果你同时用了两者,执行顺序可能不符合直觉:

function Button() {
  const ref = useRef(null);

  useEffect(() => {
    ref.current.addEventListener('click', () => {
      console.log('native'); // 先执行
    });
  }, []);

  return (
    <button ref={ref} onClick={() => console.log('react')}>
      Click {/* native → react */}
    </button>
  );
}

6.3 stopPropagation 的陷阱

如果你在原生事件中调用了 stopPropagation(),会导致同一元素上的 React 合成事件不触发(因为事件冒泡被阻止,无法到达 root)。反过来,React 的 stopPropagation() 不会阻止同一元素上的原生事件。

6.4 在 React 中使用捕获阶段

React 支持捕获阶段的合成事件,通过 onClickCapture 等 API:

function App() {
  return (
    <div onClickCapture={() => console.log('capture: div')}>
      <button onClick={() => console.log('bubble: button')}>
        Click
      </button>
    </div>
  );
}
// 输出:capture: div → bubble: button

这比手动用 addEventListener('click', handler, true) 更方便,而且不需要手动清理。


常见误区

误区一:“React 的 onClick 就是在元素上绑定了 click 事件”

不是。React 不会在 <button> 上绑定任何事件监听器。所有的事件监听器都绑定在 root 容器上(React 17+),通过事件冒泡和事件委托来工作。你在 DevTools 的 Elements 面板中看到的 onClick 只是 React 的 JSX 属性,不是 DOM 属性。

误区二:“React 的 e.stopPropagation() 和原生的效果一样”

效果不一样。React 的 e.stopPropagation() 只阻止合成事件在 React 组件树中冒泡。原生事件在 React 处理之前就已经冒泡了,所以 React 的 stopPropagation 无法”回溯”阻止原生冒泡。但从 React 17 开始,React 会在 root 容器上调用原生的 stopPropagation,所以 root 之上的原生监听器(如 document 上的)不会收到事件。

误区三:“事件池还在用,异步中要记得 e.persist()”

事件池在 React 17 已经完全移除了。在 React 17+ 中,合成事件对象不会被回收,可以在异步回调中安全使用。e.persist() 调用不报错但没有任何效果。

误区四:“React 的 onChange 等于 DOM 的 change 事件”

React 的 onChange 在行为上更接近原生的 input 事件——它在每次值变化时都触发,而不是在失去焦点时才触发(原生 change 事件在 input 元素上的行为是失焦时触发)。React 故意做了这个调整,让 onChange 更符合”值变化”的语义。


小结

本章我们系统讲解了 React 合成事件系统的设计原理和实战注意事项。

核心要点

  1. 合成事件是 React 对原生事件的跨浏览器封装,提供统一接口
  2. 事件委托:React 17+ 委托到 root 容器(而非 document),支持多实例共存
  3. 事件池:React 16 复用事件对象需要 persist(),React 17 已移除
  4. 执行顺序:原生事件 → React 合成事件(冒泡到 root 后) → document 原生事件
  5. stopPropagation 不对称:原生的能阻止合成事件,React 的不能阻止原生冒泡
  6. e.nativeEvent:访问底层原生事件对象
  7. 使用原生事件时必须手动清理,注意与合成事件的执行顺序

本章思维导图

React:合成事件(SyntheticEvent)
  • 什么是合成事件
    • 对原生事件的跨浏览器包装
    • 提供统一接口
    • 与 React 批量更新/调度系统集成
  • 事件委托
    • React 16:委托到 document
    • React 17+:委托到 root 容器
    • 为什么改:多实例共存、微前端、渐进升级
  • 事件池(Event Pooling)
    • React 16:事件对象复用,异步访问为 null
    • e.persist() 阻止回收
    • React 17:完全移除事件池
  • 执行顺序
    • 原生事件(目标 → 冒泡)→ 合成事件(root 上触发)→ document 原生事件
    • stopPropagation 不对称性
  • e.nativeEvent
    • 访问原生事件对象
    • composedPath、inputType 等场景
  • 原生事件注意事项
    • 手动清理(removeEventListener)
    • stopPropagation 的陷阱
    • onClickCapture 替代 capture: true
  • React onChange ≠ DOM change
    • React onChange ≈ 原生 input 事件

练习挑战

第一题 ⭐(基础):预测输出顺序

function App() {
  return (
    <div onClickCapture={() => console.log('A')}>
      <div onClick={() => console.log('B')}>
        <button
          onClickCapture={() => console.log('C')}
          onClick={() => console.log('D')}
        >
          Click
        </button>
      </div>
    </div>
  );
}

点击按钮后输出什么?

点击查看答案与解析
A
C
D
B

React 的合成事件也遵循 W3C 的事件流模型:先捕获从外到内,再冒泡从内到外。

  • 捕获阶段(从外到内):A(div capture)→ C(button capture)
  • 冒泡阶段(从内到外):D(button bubble)→ B(div bubble)

注意:这些都是合成事件,它们的”捕获”和”冒泡”是 React 在 root 上模拟出来的,实际上原生事件只在 root 上被监听了一次。

第二题 ⭐⭐(进阶):修复原生事件和 React 事件的冲突

下面的代码期望:点击弹窗内部不关闭,点击弹窗外部关闭。但实际表现是点击弹窗内部也会关闭。请找出原因并修复。

function Modal({ onClose, children }) {
  useEffect(() => {
    const handleClick = () => {
      onClose();
    };
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [onClose]);

  return (
    <div className="modal" onClick={(e) => e.stopPropagation()}>
      {children}
    </div>
  );
}
点击查看答案与解析

原因: React 的 e.stopPropagation() 阻止的是合成事件在 React 组件树中冒泡,但原生事件的冒泡是独立的。点击弹窗内部时,原生 click 事件仍然会冒泡到 document,触发 handleClick

修复方案一:使用原生 stopPropagation

function Modal({ onClose, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    const handleDocClick = () => onClose();
    const handleModalClick = (e) => e.stopPropagation(); // 原生事件

    document.addEventListener('click', handleDocClick);
    modalRef.current.addEventListener('click', handleModalClick);

    return () => {
      document.removeEventListener('click', handleDocClick);
      modalRef.current?.removeEventListener('click', handleModalClick);
    };
  }, [onClose]);

  return <div className="modal" ref={modalRef}>{children}</div>;
}

修复方案二:检查点击目标

function Modal({ onClose, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    const handleClick = (e) => {
      if (modalRef.current && !modalRef.current.contains(e.target)) {
        onClose();
      }
    };
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [onClose]);

  return <div className="modal" ref={modalRef}>{children}</div>;
}

方案二更推荐,因为它不依赖 stopPropagation,避免了事件传播被意外阻断的风险。

第三题 ⭐⭐⭐(综合):实现一个自定义事件委托系统

请实现一个简化版的事件委托系统,模拟 React 合成事件的核心逻辑:

  1. 所有事件监听器绑定在 root 元素上
  2. 通过 event.target 找到目标元素
  3. 从目标元素向上遍历,收集所有注册了处理器的元素
  4. 支持 stopPropagation
点击查看参考实现
class SimpleEventSystem {
  constructor(root) {
    this.root = root;
    this.handlers = new Map(); // element → { eventType → handler }

    // 在 root 上监听所有 click 事件
    this.root.addEventListener('click', (nativeEvent) => {
      this.handleEvent('click', nativeEvent);
    });
  }

  // 注册事件处理器
  bindHandler(element, eventType, handler) {
    if (!this.handlers.has(element)) {
      this.handlers.set(element, {});
    }
    this.handlers.get(element)[eventType] = handler;
  }

  // 处理事件(模拟冒泡)
  handleEvent(eventType, nativeEvent) {
    let target = nativeEvent.target;
    let stopped = false;

    // 创建合成事件
    const syntheticEvent = {
      type: eventType,
      target: nativeEvent.target,
      nativeEvent,
      stopPropagation() {
        stopped = true;
      },
    };

    // 从 target 向上遍历到 root,模拟冒泡
    while (target && target !== this.root.parentNode) {
      if (stopped) break;

      const elementHandlers = this.handlers.get(target);
      if (elementHandlers && elementHandlers[eventType]) {
        elementHandlers[eventType](syntheticEvent);
      }

      target = target.parentNode;
    }
  }
}

// 使用示例
const root = document.getElementById('root');
const eventSystem = new SimpleEventSystem(root);

const button = document.querySelector('#myBtn');
const container = document.querySelector('#container');

eventSystem.bindHandler(button, 'click', (e) => {
  console.log('button clicked');
});

eventSystem.bindHandler(container, 'click', (e) => {
  console.log('container clicked');
});

这个实现展示了 React 合成事件系统的核心思路:事件委托 + 手动冒泡 + 自定义 stopPropagation。真实的 React 实现还包括:事件捕获、Fiber 树遍历(而非 DOM 树)、批量更新集成等。


自我检测

读完本章后,对照下面的清单检验一下自己的掌握程度。

  • 能解释什么是合成事件,以及 React 为什么要封装一层
  • 能说出 React 17 事件委托位置的变化(document → root)以及原因
  • 能描述事件池的机制、e.persist() 的作用,以及 React 17 为什么移除了事件池
  • 能准确预测合成事件与原生事件的执行顺序
  • 能解释 React 的 stopPropagation() 和原生 stopPropagation() 的区别
  • 知道 e.nativeEvent 的用途和使用场景
  • 能说出在 React 中使用原生事件的至少三个注意事项
  • 能解释 React 的 onChange 和 DOM 的 change 事件的区别

购买课程解锁全部内容

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

¥89.90