React 事件系统

深入理解 React 的合成事件(SyntheticEvent)实现原理、React 17 前后的事件委托变化、事件分类、event pooling,以及在异步操作中使用事件的注意事项。

React 事件系统

为什么理解 React 事件系统很重要

React 的事件系统是 React 与 DOM 交互的核心机制。与原生 DOM 事件不同,React 实现了一套被称为"合成事件"(SyntheticEvent)的跨浏览器兼容层。这套系统不仅影响着你每天写的 onClickonChange 等事件处理器,更深刻地塑造了 React 应用的性能特征和架构选择。

理解 React 事件系统,是理解 React 内部工作原理的关键一环。它解释了为什么 React 16 和 React 17 在事件处理上有显著差异,也解释了为什么某些代码模式(如异步访问事件)在 React 18 中发生了变化。

面试定位:事件系统是 React 面试中考察"内部实现理解"的高阶话题。面试官通过候选人能否解释合成事件的实现、事件委托机制、React 17 的变化,来判断其对 React 架构的深度理解。


一、合成事件(SyntheticEvent)的实现原理

1.1 什么是合成事件

当你写 onClick={handleClick} 时,你以为这是直接绑定了一个原生 DOM 事件。实际上,React 在底层创建了一个合成事件对象,它封装了原生事件,并在事件处理函数执行完毕后自动回收。

function ClickButton() {
  const handleClick = (e) => {
    console.log(e); // SyntheticEvent 对象
    console.log(e.nativeEvent); // 原生 MouseEvent
    console.log(e.target); // 事件目标 DOM 节点
    console.log(e.currentTarget); // 绑定事件的 DOM 节点
  };

  return <button onClick={handleClick}>点击</button>;
}

1.2 合成事件的工作流程

sequenceDiagram
    participant User as 用户点击
    participant React as React 事件系统
    participant Handler as 事件处理函数
    participant Pool as Event Pool

    User->>React: click 事件
    React->>React: 创建/复用 SyntheticEvent
    React->>Handler: 调用 onClick(e)
    Handler->>Handler: 处理业务逻辑
    Handler-->>React: 返回
    React->>Pool: 回收 SyntheticEvent

关键机制

  1. 事件委托(Event Delegation):React 16 中所有事件都委托到 document,React 17+ 委托到 root 容器
  2. 统一跨浏览器 API:无论什么浏览器,e.nativeEvent 才能访问原生事件
  3. 自动池化(Pooling):事件对象被复用,执行完毕后归池

1.3 SyntheticEvent 的公共属性

所有合成事件都包含以下属性:

function handleClick(e) {
  // 布尔值,指示事件是否冒泡
  console.log(e.bubbles); // false for some events

  // 事件取消默认行为
  e.preventDefault();
  e.defaultPrevented; // true after preventDefault

  // 阻止事件冒泡
  e.stopPropagation();
  e.isPropagationStopped(); // true after stopPropagation

  // 事件阶段
  e.eventPhase; // 1=capturing, 2=target, 3=bubbling

  // 时间和时间戳
  e.timeStamp;

  // 事件类型
  e.type; // "click"

  // 目标和当前目标
  e.target;      // 触发事件的 DOM 节点
  e.currentTarget; // 绑定事件的 DOM 节点

  // 原生事件(谨慎使用)
  e.nativeEvent;
}

二、React 17 前后的事件委托变化

2.1 React 16 的事件委托

React 16 中,几乎所有事件都被委托到 document 级别

// React 16 的事件委托模型
// 所有事件 → document 级别 → React 事件系统 → 组件 onClick

这带来一些问题:

  1. 第三方库冲突:如果页面中有非 React 代码也监听同样的事件,可能产生冲突
  2. 微前端问题:多个 React 应用在同一个页面时,document 级别的委托会导致事件混乱
  3. 难以隔离:无法单独卸载一个 React 应用而不影响其他

2.2 React 17 的事件委托变化

React 17 将事件委托从 document 移到了 React 应用的 root 容器

// React 17+ 的事件委托模型
// 事件 → root 容器 → React 事件系统 → 组件 onClick
// 之前(React 16)
ReactDOM.render(<App />, document.getElementById('root'));

// React 17+(支持多个 root)
ReactDOM.createRoot(document.getElementById('root')).render(<App />);

2.3 变化的影响

对开发者的影响

  1. nativeEvent 的变化:在 React 16 中,你可能在 document 上监听事件来捕获所有 React 事件;React 17 中,root 级别的事件不会冒泡到 document
  2. 第三方库集成:一些依赖 document 级别事件监听的库可能需要更新
  3. 测试变化:使用 enzyme 的测试可能需要调整

对架构的影响

// React 16:一个页面上多个 React 应用共享 document 事件
// 容易产生冲突
ReactDOM.render(<App1 />, container1); // 事件到 document
ReactDOM.render(<App2 />, container2); // 事件也到 document

// React 17:每个 root 独立管理事件
const root1 = ReactDOM.createRoot(container1);
const root2 = ReactDOM.createRoot(container2);
root1.render(<App1 />); // 事件只在自己的 root 内
root2.render(<App2 />); // 事件隔离

三、事件分类

3.1 事件分类概览

React 将 DOM 事件分为几大类:

类别 事件 特点
剪贴板事件 onCopy, onCut, onPaste 可通过 e.clipboardData 访问数据
合成事件 onCompositionEnd, onCompositionStart, onCompositionUpdate 用于 IME 输入法
键盘事件 onKeyDown, onKeyUp, onKeyPress 通过 e.keye.code 访问
焦点事件 onFocus, onBlur 事件委托到 root,不冒泡
表单事件 onChange, onInput, onReset, onSubmit 表单交互核心
鼠标事件 onClick, onContextMenu, onDoubleClick, onDrag, onMouseDown, onMouseEnter, onMouseLeave, onMouseMove, onMouseOut, onMouseOver, onMouseUp 鼠标交互
触摸事件 onTouchCancel, onTouchEnd, onTouchMove, onTouchStart 移动端触摸
UI 元素事件 onScroll 滚动
滚轮事件 onWheel 鼠标滚轮
媒体事件 onAbort, onCanPlay, onError, onLoadedMetadata, onPlay, onWaiting 媒体控制
图片事件 onLoad, onError 资源加载

3.2 焦点事件的变化

焦点事件(onFocus/onBlur)在 React 17 有一个重要变化:它们不再冒泡

// React 16
function App() {
  return (
    <div onFocus={() => console.log('div focused')}>
      <input />
    </div>
  );
}
// 聚焦 input 时,div 的 onFocus 也会被触发(冒泡)

// React 17+
function App() {
  return (
    <div onFocus={() => console.log('div focused')}>
      <input />
    </div>
  );
}
// 聚焦 input 时,div 的 onFocus 不会被触发

这是因为 React 17 使用了 focusin/focusout 替代了 focus/blur,而 focusin/focusout 本身是不冒泡的。

3.3 onChange vs onInput

React 的 onChange 和原生 DOM 的 onInput 是不同的:

// React onChange:在输入值改变且失去焦点时触发(像 HTML 的 change 事件)
// 对于 text input,等同于原生 input + blur 的组合

// React onInput:输入时实时触发(像 HTML5 的 input 事件)
function InputComponent() {
  return (
    <>
      {/* React onChange */}
      <input
        onChange={(e) => console.log('onChange:', e.target.value)}
        placeholder="React onChange"
      />

      {/* 原生 input 事件 */}
      <input
        onInput={(e) => console.log('onInput:', e.target.value)}
        placeholder="Native input"
      />
    </>
  );
}

3.4 鼠标事件的行为

鼠标事件中有一个特别值得注意的模式——onMouseEnteronMouseLeave

// onMouseEnter/onMouseLeave 不使用事件冒泡
// 它们从离开的元素移动到进入的元素时直接触发

function Tooltip() {
  const [show, setShow] = useState(false);

  return (
    <div
      onMouseEnter={() => setShow(true)}
      onMouseLeave={() => setShow(false)}
    >
      <span>Hover me</span>
      {show && <TooltipContent />}
    </div>
  );
}

这些事件不使用标准的 DOM 事件冒泡机制,而是使用事件委托的变体。


四、Event Pooling 和 React 17 的变化

4.1 什么是 Event Pooling

在 React 16 及之前版本中,合成事件是池化(pooled)的。这意味着:

  1. 事件对象在事件处理函数执行完毕后被"归还到池中"
  2. 同一事件池中的事件对象会被复用
  3. 这减少了垃圾回收压力,提高性能
// React 16 的 event pooling 行为
function handleClick(e) {
  console.log(e.target); // 有值

  // 异步访问事件的问题
  setTimeout(() => {
    console.log(e.target); // null!(事件已被回收)
    console.log(e.isPropagationStopped()); // false!(状态也被重置)
  }, 0);
}

4.2 React 17 的变化

React 17 移除了 event pooling。这意味着:

// React 17+:不再需要担心事件池化
function handleClick(e) {
  console.log(e.target); // 始终有效

  setTimeout(() => {
    console.log(e.target); // 仍然有效!
    console.log(e.isPropagationStopped()); // 仍然有效!
  }, 0);
}

4.3 迁移影响

如果你有 React 16 的代码依赖于 event pooling 行为(如异步访问事件属性),在迁移到 React 17 时需要更新:

// React 16 写法(依赖 pooling)
function handleClick(e) {
  const target = e.target; // 提前保存

  setTimeout(() => {
    console.log(target); // 访问保存的值
  }, 0);
}

// React 17+ 写法(直接使用 e)
function handleClick(e) {
  setTimeout(() => {
    console.log(e.target); // 直接访问
    console.log(e.isPropagationStopped()); // 直接访问
  }, 0);
}

五、event.stopPropagation vs event.isPropagationStopped

5.1 两者的区别

function handleClick(e) {
  // 阻止事件冒泡到父元素
  e.stopPropagation();

  // 检查事件是否已停止冒泡
  // 静态方法,调用 stopPropagation 后返回 true
  console.log(e.isPropagationStopped()); // true
}

stopPropagation:实例方法,调用它阻止事件继续冒泡

isPropagationStopped:静态方法(实际上是挂载在事件对象上的函数),返回是否已调用过 stopPropagation

5.2 实际使用场景

function Parent() {
  const handleParentClick = () => console.log('Parent clicked');
  return (
    <div onClick={handleParentClick}>
      <button onClick={(e) => {
        e.stopPropagation(); // 点击按钮不会触发 Parent 的 onClick
        console.log('Button clicked');
      }}>
        Click me
      </button>
    </div>
  );
}

5.3 注意事项

stopPropagation 只阻止通过 React 事件系统的事件冒泡,不阻止通过原生 addEventListener 添加的事件监听器

function Component() {
  useEffect(() => {
    // 这个监听器不受 React 的 stopPropagation 影响
    document.getElementById('btn').addEventListener('click', (e) => {
      console.log('Native listener');
    });
  }, []);

  return (
    <button
      id="btn"
      onClick={(e) => {
        e.stopPropagation(); // 不会阻止上面的 addEventListener
        console.log('React handler');
      }}
    >
      Click
    </button>
  );
}

六、在异步操作中使用事件的注意事项

6.1 React 16 的陷阱

如前所述,React 16 的 event pooling 会在事件处理函数结束后回收事件对象:

// React 16 错误写法
function SearchInput() {
  const handleChange = async (e) => {
    const query = e.target.value; // 立即读取

    // 如果 query 变化快,旧的事件可能在新事件触发时被回收
    const results = await searchAPI(query);
    // 但此时 e 可能已经被回收,某些属性可能不准确

    // 如果你尝试访问 e.target.value
    // 在 pooling 下可能有问题
  };

  return <input onChange={handleChange} />;
}

6.2 React 17+ 的改进

React 17 移除了 event pooling,异步访问事件变得更加安全:

// React 17+ 推荐写法
function SearchInput() {
  const handleChange = (e) => {
    // 可以安全地在异步操作中使用
    searchAPI(e.target.value).then(setResults);
  };

  return <input onChange={handleChange} />;
}

6.3 如果必须保留引用

在某些场景下,你可能仍然想保留事件对象的引用(不是因为 pooling,而是为了访问事件触发时的状态):

// 推荐:直接提取需要的值
function handleChange(e) {
  const value = e.target.value;
  // 使用 value,而不是 e
  doSomething(value);
}

// 如果需要阻止默认行为,且在异步操作中
function FormComponent() {
  const handleSubmit = (e) => {
    e.preventDefault(); // 立即调用
    const formData = new FormData(e.target);
    // 异步处理
    submitForm(formData);
  };

  return <form onSubmit={handleSubmit} />;
}

6.4 Passive Event Listeners

对于滚动相关的触摸事件,可以使用 passive event listener 优化性能:

// 告诉浏览器这个事件处理器不会调用 preventDefault
// 浏览器可以更早地开始滚动处理
useEffect(() => {
  const handler = (e) => {
    // 不能调用 e.preventDefault()
    setScrollY(window.scrollY);
  };

  window.addEventListener('scroll', handler, { passive: true });

  return () => window.removeEventListener('scroll', handler);
}, []);

React 的 onTouchMoveonWheel 默认使用 passive listeners。


七、面试高频问题

Q: 什么是合成事件(SyntheticEvent)?它和原生 DOM 事件有什么区别?

回答要点:合成事件是 React 封装原生 DOM 事件后创建的对象,它提供了统一的跨浏览器 API。主要区别包括:合成事件是 React 层面的封装,原生事件是浏览器底层事件;在 React 16 中事件被池化(React 17 移除了这个特性);React 17+ 将事件委托从 document 改为 root 容器。合成事件包含所有原生事件的属性,但通过 e.nativeEvent 才能访问原生事件对象。

Q: React 17 在事件系统上有什么重大变化?

回答要点:React 17 最大的变化是将事件委托从 document 级别改为 root 容器级别。这解决了多个 React 应用在同一个页面共存的冲突问题,也使得卸载单个 React 应用不影响其他应用。此外,React 17 移除了 event pooling,事件对象可以在异步操作中安全使用。还有一个变化是焦点事件(onFocus/onBlur)不再冒泡,因为使用了 focusin/focusout 替代 focus/blur。

Q: event pooling 是什么?React 17 移除了它有什么影响?

回答要点:Event pooling 是 React 16 中的一种性能优化机制——事件对象在事件处理函数执行完毕后被"归还到池中",后续事件可以复用这个对象。这导致一个常见陷阱:在异步操作中访问事件属性会得到 null 或错误值。React 17 移除了 event pooling,使得事件对象可以在异步操作中安全使用。这个变化简化了代码(不再需要提前保存事件属性),但也意味着开发者需要更新依赖于 pooling 行为的代码。


延展阅读