Skip to content

深入理解 React 合成事件系统

事件委托机制、合成事件对象、事件优先级、与原生事件的关系、React 17+ 的变化

什么是合成事件?

定义:React 合成事件(SyntheticEvent)是 React 对浏览器原生事件的跨浏览器封装。React 不会将事件处理器直接绑定在对应的 DOM 节点上,而是通过事件委托将所有事件统一绑定在根节点,由 React 内部的事件系统分发给对应的组件。

涉及场景

  • onClick、onChange 等所有事件:全部是合成事件
  • 事件冒泡控制e.stopPropagation() 阻止的是 React 的合成冒泡
  • 原生事件混用:需要理解合成事件与原生事件的执行顺序
  • 性能优化:事件委托减少了大量 DOM 事件监听器

一、事件委托机制

React 16 vs React 17+ 的区别

React 16:事件委托到 document
  document  ← 所有事件绑定在这里
    └── #root
          └── <App>
                └── <button onClick={handler}>

问题:
  - 多个 React 应用共存时事件冲突
  - e.stopPropagation() 无法阻止事件到达 document
  - 与微前端、jQuery 等共存时出 bug

React 17+:事件委托到 root 容器
  document
    └── #root  ← 事件绑定在这里
          └── <App>
                └── <button onClick={handler}>

优势:
  - 多个 React 应用互不干扰
  - e.stopPropagation() 阻止事件传播到 root 之外
  - 与其他库共存更安全

事件注册流程

javascript
// 简化的事件注册流程

// 1. createRoot 时,在 root 容器上注册所有事件
function createRoot(container) {
  // React 会在 container 上注册几乎所有原生事件
  allNativeEvents.forEach(eventName => {
    // 捕获阶段
    container.addEventListener(eventName, dispatchEvent, true);
    // 冒泡阶段
    container.addEventListener(eventName, dispatchEvent, false);
  });
}

// 2. 用户点击 button 时
// 原生事件冒泡到 root container
// → React 的 dispatchEvent 被调用

// 3. dispatchEvent 内部
function dispatchEvent(nativeEvent) {
  // a. 从 nativeEvent.target 获取对应的 Fiber 节点
  const targetFiber = getClosestFiber(nativeEvent.target);

  // b. 从 targetFiber 向上收集所有事件处理器(模拟捕获+冒泡)
  const listeners = collectListeners(targetFiber, eventName);

  // c. 创建合成事件对象
  const syntheticEvent = new SyntheticEvent(nativeEvent);

  // d. 按捕获→冒泡顺序执行处理器
  for (const listener of listeners) {
    listener(syntheticEvent);
    if (syntheticEvent.isPropagationStopped()) break;
  }
}

事件收集(模拟冒泡)

javascript
function collectListeners(targetFiber, eventName) {
  const captureListeners = [];  // onClickCapture
  const bubbleListeners = [];   // onClick

  let fiber = targetFiber;
  while (fiber !== null) {
    const { stateNode, tag } = fiber;
    if (tag === HostComponent && stateNode) {
      // 收集捕获阶段处理器
      const captureHandler = fiber.memoizedProps[eventName + 'Capture'];
      if (captureHandler) captureListeners.unshift(captureHandler);

      // 收集冒泡阶段处理器
      const bubbleHandler = fiber.memoizedProps[eventName];
      if (bubbleHandler) bubbleListeners.push(bubbleHandler);
    }
    fiber = fiber.return; // 向上遍历
  }

  // 先捕获(从外到内),后冒泡(从内到外)
  return [...captureListeners, ...bubbleListeners];
}

二、SyntheticEvent 对象

结构

javascript
class SyntheticEvent {
  constructor(nativeEvent) {
    // 常用属性直接从原生事件复制
    this.type = nativeEvent.type;
    this.target = nativeEvent.target;
    this.currentTarget = null; // React 在分发时设置
    this.timeStamp = nativeEvent.timeStamp;
    this.nativeEvent = nativeEvent; // 保留原生事件引用

    // 鼠标事件特有
    this.clientX = nativeEvent.clientX;
    this.clientY = nativeEvent.clientY;
    this.button = nativeEvent.button;

    // 键盘事件特有
    this.key = nativeEvent.key;
    this.code = nativeEvent.code;

    // 状态
    this._propagationStopped = false;
    this._defaultPrevented = false;
  }

  preventDefault() {
    this._defaultPrevented = true;
    this.nativeEvent.preventDefault();
  }

  stopPropagation() {
    this._propagationStopped = true;
    // 注意:只阻止 React 的合成冒泡
    // 不会阻止原生事件的冒泡(因为原生事件已经到了 root)
  }

  isPropagationStopped() {
    return this._propagationStopped;
  }
}

React 17+ 的变化:不再使用事件池

jsx
// React 16:事件对象被复用(事件池)
function handleClick(e) {
  console.log(e.type); // 'click'
  setTimeout(() => {
    console.log(e.type); // null(事件对象已被回收重用)
  });
  // 必须调用 e.persist() 才能保留
}

// React 17+:事件对象不再复用
function handleClick(e) {
  console.log(e.type); // 'click'
  setTimeout(() => {
    console.log(e.type); // 'click' ✅ 正常访问
  });
  // 不需要 persist()
}

三、事件优先级

React 将事件按用户体验紧急程度分为不同优先级,对应不同的更新 Lane:

javascript
// 离散事件(Discrete)— 最高优先级 → SyncLane
// click, keydown, keyup, input, change, focus, blur
// 特点:用户明确的交互动作,必须立即响应

// 连续事件(Continuous)— 高优先级 → InputContinuousLane
// mousemove, scroll, drag, touchmove, wheel, pointermove
// 特点:高频触发,需要流畅但允许合并

// 默认事件(Default)— 普通优先级 → DefaultLane
// load, error, message, animation 相关
// 特点:非用户直接交互

事件优先级如何影响 setState

jsx
function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleInput(e) {
    // 这个 setState 在 input 事件中触发
    // → Discrete 事件 → SyncLane → 同步执行
    setQuery(e.target.value);
  }

  function handleScroll() {
    // 这个 setState 在 scroll 事件中触发
    // → Continuous 事件 → InputContinuousLane → 可能被批处理
    setScrollY(window.scrollY);
  }
}

四、合成事件与原生事件的关系

执行顺序

jsx
function App() {
  const ref = useRef(null);

  useEffect(() => {
    // 原生事件(绑定在 button DOM 上)
    ref.current.addEventListener('click', () => {
      console.log('1. 原生事件 - button');
    });

    document.addEventListener('click', () => {
      console.log('4. 原生事件 - document');
    });
  }, []);

  return (
    <div onClick={() => console.log('3. React 事件 - div(冒泡)')}>
      <button
        ref={ref}
        onClick={() => console.log('2. React 事件 - button')}
      >
        点击
      </button>
    </div>
  );
}

// 点击 button 的输出顺序:
// 1. 原生事件 - button          (原生事件在 DOM 节点上,最先执行)
// 2. React 事件 - button        (原生冒泡到 root → React 分发)
// 3. React 事件 - div(冒泡)    (React 模拟冒泡)
// 4. 原生事件 - document         (原生继续冒泡到 document)

执行顺序图解

用户点击 button

原生捕获阶段:document → root → div → button

button 上的原生事件执行(addEventListener 绑定的)

原生冒泡阶段:button → div → root(被 React 拦截)

React 的 dispatchEvent 执行:
  收集 Fiber 树上的 onClick 处理器
  模拟捕获:onClickCapture(从外到内)
  模拟冒泡:onClick(从内到外)

原生冒泡继续:root → document

document 上的原生事件执行

stopPropagation 的影响

jsx
// React 的 stopPropagation 只影响 React 事件
<div onClick={() => console.log('div')}>
  <button onClick={(e) => {
    e.stopPropagation(); // 阻止 React 冒泡到 div
    console.log('button');
  }}>
    点击
  </button>
</div>
// 输出:button(div 的 onClick 不执行)
// 但 document 上的原生事件仍会执行

// 要同时阻止原生冒泡:
<button onClick={(e) => {
  e.stopPropagation();            // 阻止 React 冒泡
  e.nativeEvent.stopPropagation(); // 阻止原生冒泡(到 document)
}}>

五、特殊事件处理

onChange 的特殊性

jsx
// React 的 onChange ≠ 原生的 change 事件
// 原生 change:失去焦点时触发
// React onChange:每次输入都触发(实际监听的是 input 事件)

<input onChange={(e) => console.log(e.target.value)} />
// 每输入一个字符都会触发

// React 内部将 onChange 映射到:
// input 事件(文本输入)
// change 事件(checkbox/radio/select)
// 组合了多个原生事件来实现一致的行为

onScroll 不冒泡

jsx
// React 的 onScroll 不会冒泡(匹配原生 scroll 行为)
<div onScroll={handleScroll}>
  <div style={{ overflow: 'auto', height: 200 }}>
    {/* 这里滚动不会触发外层 div 的 onScroll */}
    <LongContent />
  </div>
</div>

事件捕获

jsx
// 使用 Capture 后缀进行捕获阶段监听
<div onClickCapture={() => console.log('1. div 捕获')}>
  <button
    onClickCapture={() => console.log('2. button 捕获')}
    onClick={() => console.log('3. button 冒泡')}
  >
    点击
  </button>
</div>
// 输出顺序:1 → 2 → 3

六、React 19 事件系统变化

异步事件处理中的自动批处理

jsx
// React 18+:所有场景自动批处理(包括异步)
async function handleClick() {
  const data = await fetchData();
  setLoading(false);  // ┐
  setData(data);       // ├─ 只触发一次渲染(自动批处理)
  setError(null);      // ┘
}

事件处理器中使用 Transition

jsx
function handleClick() {
  // 紧急更新
  setInputValue(e.target.value);

  // 非紧急更新(不阻塞输入响应)
  startTransition(() => {
    setSearchResults(search(e.target.value));
  });
}

面试高频题

Q: React 为什么使用合成事件而不是直接用原生事件?

  1. 跨浏览器一致性:抹平不同浏览器的事件差异
  2. 事件委托优化:所有事件统一绑定在 root,减少 DOM 事件监听器数量
  3. 与 Fiber 架构集成:事件触发时可以根据 Fiber 树精确分发,支持优先级调度
  4. 控制事件生命周期:React 可以控制事件的创建、分发和回收

Q: React 17 为什么把事件委托从 document 改到 root?

  1. 多 React 实例共存:微前端场景下多个 React 应用不互相干扰
  2. stopPropagation 语义正确:阻止冒泡不会超出 React 应用边界
  3. 与第三方库兼容:jQuery 等库在 document 上的事件不被 React 影响
  4. 渐进式升级:允许页面中同时存在 React 16 和 React 17 的组件

Q: 合成事件中的 e.stopPropagation() 能阻止原生事件冒泡吗?

不能完全阻止。e.stopPropagation() 只阻止 React 的合成冒泡(Fiber 树上的事件分发)。原生事件在到达 root 容器时已经完成了冒泡。如果需要阻止原生冒泡到 root 之外(如 document),需要 e.nativeEvent.stopPropagation()

Q: React 的 onChange 和原生 change 事件一样吗?

不一样。原生 change 事件在输入框失去焦点时才触发,而 React 的 onChange每次输入时都触发(内部监听的是原生 input 事件)。React 这样设计是为了提供更符合直觉的实时输入监听行为。