前端知库

React 的合成事件是什么?和原生事件有什么区别?

React 给事件处理函数传的是包装过的事件对象,接口和原生事件一致,原生事件在 e.nativeEvent 上。React 17 起事件监听挂在根容器上而不是 document,并去掉了事件池。onScroll 不冒泡,onChange 每次输入都触发,和原生 change 不同。

参考答案

简答

在 React 里写 <button onClick={(e) => ...}>,e 不是浏览器原生的事件对象,而是 React 包装过的一个对象,文档叫它 React 事件对象,也常被叫作合成事件。它的属性和方法跟原生事件基本一样,比如 target、preventDefault()、stopPropagation(),同时抹平了一些浏览器差异。需要原生事件时,从 e.nativeEvent 读。

React 并不是给每个按钮都调用 addEventListener。它在根节点上统一监听各类事件,事件冒泡到根节点后,React 再根据触发事件的元素找到对应的组件,按组件树的顺序调用你写的处理函数,这就是事件委托。React 17 之前监听挂在 document 上,17 起改成挂在 createRoot 传入的根容器上,这样同一个页面里嵌入多个 React 版本也不会互相干扰。

和原生事件的几个区别要记住。React 17 去掉了事件池,旧版本里事件对象在处理函数执行完后会被清空复用,异步回调里读 e.target 会出问题,现在没有这个限制了。除了 onScroll,React 里的事件都会沿组件树冒泡。onChange 每输入一个字符都会触发,行为对应原生的 input 事件,而不是原生 change 那样失焦才触发。

要注意,React 的冒泡是沿着组件树走的,所以通过 Portal 渲染到 body 下的弹窗,点击事件仍然会冒泡到 React 父组件上,哪怕它们在 DOM 里并不是父子关系。

React 事件和原生事件谁先执行

下面在同一个按钮上同时有 React 监听和原生监听,外层 document 也加了一个原生监听:

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

  useEffect(() => {
    const button = ref.current;
    const onNative = () => console.log('按钮原生');
    const onDocument = () => console.log('document 原生');
    button.addEventListener('click', onNative);
    document.addEventListener('click', onDocument);
    return () => {
      button.removeEventListener('click', onNative);
      document.removeEventListener('click', onDocument);
    };
  }, []);

  return <button ref={ref} onClick={() => console.log('React')}>点我</button>;
}

点击按钮,依次输出“按钮原生”“React”“document 原生”。

为什么按钮自己的原生监听先于 React?因为 React 的处理函数并没有绑在按钮上,而是等事件冒泡到根容器时才统一分发。事件先在按钮上触发原生监听,再往上冒泡到根容器,React 才调用 onClick,然后事件继续冒泡到 document。

这也说明,在 React 的 onClick 里调用 e.stopPropagation(),能阻止事件继续冒泡到 document 上的原生监听,却挡不住按钮自己的原生监听,因为那个已经执行过了。在 React 17 之前,React 监听挂在 document 上,这种情况下阻止冒泡就拦不住 document 上别的监听,这正是 17 改成挂在根容器上的原因之一。

事件池去掉以后有什么变化

React 16 及更早版本,为了在老浏览器上节省内存,会复用事件对象,处理函数一结束就把所有字段设成 null:

JSX
function handleChange(e) {
  setData((data) => ({ ...data, text: e.target.value }));
}

这段代码在 React 16 里会报错,因为 setData 的更新函数执行时,e 已经被清空了,当时要先调用 e.persist() 才行。React 17 移除了事件池,现在这样写没有问题。

常见追问

React 里怎么阻止默认行为?能不能 return false?

要显式调用 e.preventDefault()。原生 HTML 里内联处理函数 return false 可以阻止默认行为,React 里不行。

捕获阶段的事件怎么写?

在事件名后面加 Capture,比如 onClickCapture。它会在捕获阶段执行,比子元素的 onClick 更早,适合做埋点这类不管子元素是否阻止冒泡都要执行的逻辑。

为什么 onFocus 和 onBlur 能冒泡?

原生的 focus 和 blur 不冒泡。React 17 起 onFocus、onBlur 底层改用原生的 focusin、focusout,它们会冒泡,所以父元素也能监听到子元素获得或失去焦点。