说说 React 的事件机制?

一、是什么

React 实现了一套独立于浏览器原生事件的合成事件系统(SyntheticEvent)。当你在 React 中编写事件处理代码时,你接收到的事件对象不是浏览器原生的 Event,而是 React 封装的 SyntheticEvent。

function Button() {
  function handleClick(event) {
    // event 是 SyntheticEvent,不是原生 MouseEvent
    console.log(event.constructor.name); // SyntheticBaseEvent
    console.log(event.nativeEvent); // 原生 MouseEvent

    event.preventDefault(); // 合成事件上的方法
    event.stopPropagation(); // 合成事件上的方法
  }

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

React 合成事件的设计目标:

  1. 跨浏览器一致性:抹平不同浏览器事件 API 的差异
  2. 性能优化:通过事件委托减少事件监听器数量
  3. 统一管理:React 可以控制事件的优先级和调度

二、事件委托机制

2.1 基本原理

React 不会为每个元素单独绑定事件监听器,而是利用事件冒泡机制,在根节点统一监听所有事件:

function App() {
  return (
    <div onClick={() => console.log("div")}>
      <button onClick={() => console.log("button")}>
        <span onClick={() => console.log("span")}>点击</span>
      </button>
    </div>
  );
}

// 实际上,React 只在根容器上注册了一个 click 监听器
// 当点击 span 时,事件冒泡到根容器
// React 根据 event.target 和 Fiber 树,按正确顺序调用处理函数
// 输出: span → button → div

2.2 React 16 vs React 17+ 的委托目标

这是一个重要的版本差异:

React 16:
  document ← 所有事件都委托到 document
    └── #root
        └── App
            └── button (onClick)

React 17+:
  document
    └── #root ← 事件委托到 React 根容器
        └── App
            └── button (onClick)

React 17 将事件委托的目标从 document 改为了 React 根容器(createRoot 挂载的 DOM 节点)。这个改变的意义:

// 多个 React 版本/实例共存时不再互相干扰
const root1 = document.getElementById("root1");
const root2 = document.getElementById("root2");

// React 17+: 各自的事件委托到各自的根容器,互不影响
createRoot(root1).render(<AppV18 />);
createRoot(root2).render(<AppV18 />);

// React 16: 都委托到 document,可能互相干扰

还有一个实际影响:

function App() {
  useEffect(() => {
    // React 16: document 上的监听器先于 React 的事件处理执行
    // React 17+: 这个监听器会在 React 事件处理之后执行(因为 React 事件在 #root 上处理,先于 document)
    document.addEventListener("click", () => {
      console.log("document click");
    });
  }, []);

  return (
    <button
      onClick={(e) => {
        e.stopPropagation();
        console.log("button click");
      }}
    >
      点击
    </button>
  );
}

// React 16: 输出 "document click" 和 "button click"
// stopPropagation 无法阻止 document 监听器(因为 React 事件本身就在 document 上)

// React 17+: 只输出 "button click"
// stopPropagation 正确阻止了事件继续冒泡到 document

三、SyntheticEvent 对象

3.1 结构和属性

合成事件对象实现了与原生事件相同的接口:

function EventDemo() {
  function handleClick(event) {
    // 通用属性
    event.type; // "click"
    event.target; // 触发事件的 DOM 元素
    event.currentTarget; // 绑定事件的 DOM 元素
    event.timeStamp; // 事件时间戳
    event.bubbles; // 是否冒泡
    event.cancelable; // 是否可取消默认行为

    // 鼠标事件特有
    event.clientX;
    event.clientY;
    event.pageX;
    event.pageY;
    event.button;

    // 键盘事件特有
    // event.key
    // event.code
    // event.altKey / event.ctrlKey / event.shiftKey / event.metaKey

    // 获取原生事件
    event.nativeEvent; // 浏览器原生 Event 对象

    // 阻止默认行为和冒泡
    event.preventDefault();
    event.stopPropagation();
  }

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

3.2 事件池(Event Pooling)— 已在 React 17 移除

React 16 及之前版本使用了事件池机制来优化性能。合成事件对象在事件回调执行后会被回收,所有属性被重置为 null:

// React 16 的问题
function OldBehavior() {
  function handleClick(event) {
    console.log(event.type); // "click"

    setTimeout(() => {
      console.log(event.type); // React 16: null(已被回收)
      // React 17+: "click"(正常)
    }, 100);
  }

  // React 16 的解决方案
  function handleClickFixed(event) {
    event.persist(); // 从事件池中移除,保持引用
    setTimeout(() => {
      console.log(event.type); // "click"
    }, 100);
  }
}

React 17 移除了事件池机制,事件对象不再被复用,event.persist() 仍然存在但不执行任何操作。这个改变简化了开发者的心智模型。

四、事件传播

4.1 捕获和冒泡

React 同时支持捕获阶段和冒泡阶段的事件处理:

function PropagationDemo() {
  return (
    <div
      onClickCapture={() => console.log("div capture")}
      onClick={() => console.log("div bubble")}
    >
      <button
        onClickCapture={() => console.log("button capture")}
        onClick={() => console.log("button bubble")}
      >
        点击
      </button>
    </div>
  );
}

// 点击 button 后输出顺序:
// div capture
// button capture
// button bubble
// div bubble

捕获阶段的事件处理使用 onXxxCapture 命名。

4.2 阻止冒泡

function StopPropagationDemo() {
  return (
    <div onClick={() => console.log("外层被点击")}>
      <button
        onClick={(e) => {
          e.stopPropagation(); // 阻止合成事件冒泡
          console.log("按钮被点击");
        }}
      >
        点击
      </button>
    </div>
  );
}
// 只输出: "按钮被点击"

4.3 合成事件与原生事件的执行顺序

function EventOrderDemo() {
  const buttonRef = useRef(null);

  useEffect(() => {
    const button = buttonRef.current;
    const root = document.getElementById("root");

    button.addEventListener("click", () => {
      console.log("1. 原生事件: button");
    });

    root.addEventListener("click", () => {
      console.log("4. 原生事件: root");
    });

    document.addEventListener("click", () => {
      console.log("5. 原生事件: document");
    });
  }, []);

  return (
    <button
      ref={buttonRef}
      onClick={() => console.log("3. 合成事件: button")}
    >
      <span onClick={() => console.log("2. 合成事件: span")}>点击</span>
    </button>
  );
}

// React 17+ 点击 span 后的输出顺序:
// 1. 原生事件: button(原生事件先冒泡到 button)
// 2. 合成事件: span(冒泡到 root 时,React 处理合成事件)
// 3. 合成事件: button
// 4. 原生事件: root(继续原生冒泡)
// 5. 原生事件: document

执行顺序规则(React 17+):

  1. 原生事件从 target 向上冒泡到 React 根容器之前的节点
  2. React 根容器上的委托监听器触发,按 Fiber 树顺序执行合成事件处理函数
  3. 原生事件继续从 React 根容器向上冒泡到 document

五、与原生 DOM 事件的差异

5.1 命名差异

// HTML 原生事件:全小写
<button onclick="handleClick()">

// React 合成事件:驼峰命名
<button onClick={handleClick}>

// HTML 原生:字符串
<form onsubmit="return false">

// React:函数引用,且阻止默认行为必须显式调用
<form onSubmit={(e) => { e.preventDefault(); }}>

5.2 阻止默认行为

HTML 中可以 return false 阻止默认行为,但 React 中必须显式调用 e.preventDefault()。同样,原生 DOM 事件的 this 指向触发事件的元素,React 合成事件中没有这种隐式绑定。

5.4 onChange 的特殊行为

onChange 在 React 中的行为与原生 change 事件不同——React 的 onChange 在每次输入时都会触发(类似原生 input 事件),而原生 change 事件只在失焦时触发。

六、访问原生事件

通过合成事件的 nativeEvent 属性可获取原生事件对象。如果需要直接使用原生事件(如集成第三方库),可通过 useRef + addEventListeneruseEffect 中绑定:

function NativeEventAccess() {
  const divRef = useRef(null);

  useEffect(() => {
    const div = divRef.current;
    const handler = (e) => console.log("原生事件", e);
    div.addEventListener("click", handler);
    return () => div.removeEventListener("click", handler);
  }, []);

  return (
    <div ref={divRef}>
      <button onClick={(e) => console.log(e.nativeEvent)}>点击</button>
    </div>
  );
}

七、总结

React 合成事件系统是 React 架构的重要组成部分,核心要点:

  1. 事件委托:React 17+ 将事件委托到根容器(非 document),支持多 React 实例共存
  2. 合成事件:跨浏览器一致的事件接口,通过 nativeEvent 属性访问原生事件
  3. 事件池已移除:React 17 移除了事件池机制,事件对象可以正常异步访问
  4. 命名规范:驼峰命名,传递函数引用而非字符串
  5. 执行顺序:原生事件(target → root 前)→ 合成事件 → 原生事件(root → document)

面试时需要特别注意 React 16 和 17+ 在事件委托目标、事件池行为上的版本差异,以及合成事件与原生事件混用时的执行顺序。