首页 深入学习React的合成事件
文章
取消

深入学习React的合成事件

作为前端开发者,我们对浏览器事件再熟悉不过了,它的传播会经过捕获和冒泡两个阶段。jquery对于事件的处理比较容易理解,通过一定的封装去适配不同的浏览器,让开发者使用起来更方便。而React对事件的处理就没这么简单了。

React合成事件(SyntheticEvent)是浏览器原生事件的上层封装。SyntheticEvent和原生事件有类似的结构,比如都有stopPropagationpreventDefault方法。但它会另外增加一些属性或方法,比如nativeEvent属性用于保存原生事件,又比如persist方法用于避免事件被释放掉(React17移除了该方法)。

1
2
3
4
5
6
7
8
9
10
function App() {
  const onClick = (event) => {
    // 这里的event不是真实的DOM Event
    event.stopPropagation();
    console.log(event._reactName);
  };
  return (
    <button id="btn" onClick={onClick}>Click Here</button>
  );
}

上面这段代码的运行结果并不等价于<button id="btn" onclick={onClick()}>Click Here</button>或者document.getElementById('#btn').addEventListener('click', onClick)

因为React的事件不是挂载到jsx定义的DOM节点上,而是通过事件代理挂载到某个祖先节点上。React 16.x及以前的版本这个祖先节点是document,而React 17之后是根容器,也就是下面代码的rootNode

1
2
const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);

也可以看下面的经典图: image.png

React 16.x及以前的合成事件

以下分析基于React 16.8。

事件委托

React每次render都会递归构建Fiber树,这个过程中会设置节点的属性,其中有两个很重要的点:

  1. 使用前缀为__reactInternalInstance$的key将Fiber节点关联到DOM实例上,方便查找;
  2. 如果节点上有事件相关的props,比如onClick,则在document上绑定该类型的监听,类似这样:
1
document.addEventListener('click', listener, false);

当用户点击页面元素时,事件会经过捕获/冒泡阶段。根据当前所在的阶段,执行listener。但这里的listener并不等同于我们在jsx上定义的onClick之类的事件回调,它是对这些事件回调的深度封装,里面做了很多事情,最后会执行这些回调。

并不是所有的事件都会委托到document,有些事件还是直接绑定到当前元素,比如img的load事件、input的invalid事件、video和audio的相关事件等。

事件执行顺序如下图: react 16 event.png React事件的capture阶段等到原生事件的bubble阶段结束后才执行。

事件池

为了提高性能,React使用事件池来管理事件。

不同的事件类比如SyntheticInputEvent、SyntheticClipboardEvent、SyntheticAnimationEvent等都是扩展自SyntheticEvent基类,每个类有自己的事件池,用来管理事件的实例。

事件的创建和释放

回到上文说到的listener,它的执行过程,也是合成事件的创建和释放过程。

当我们需要合成事件的实例时,React并不是每次都重新创建,而是从池子里面取出一个,并修改它的一些属性。

真实的事件监听都绑定到了document上。当我们点击一个DOM元素,事件必须先经过捕获再冒泡到document,然后才执行listener回调。

前面说过,DOM节点通过前缀为__reactInternalInstance$的key,指向了对应的Fiber实例。执行listener时,通过event参数可以拿到事件源对应的DOM节点和Fiber实例,然后递归往上收集我们定义的事件回调(比如点击按钮时的onClick方法)和Fiber实例,结果分别保存在event._dispatchListenersevent._dispatchInstances。这个收集过程模拟了捕获->冒泡这样一个事件传播的顺序,所以之后正序递归执行event._dispatchListeners里的回调方法就可以了。

执行完之后重置event对象,并维护事件池,这就是事件的释放过程。

event.persist

合成事件执行完之后,React会释放事件对象(大部分属性置为null)。如果我们想异步访问事件对象里的属性,就会出错。比如下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function TextInput() {
  const [input, setInput] = useState({
    text: '',
    editCount: 0,
  });
  const onChange = useCallback((event) => {
    setInput((prev) => ({
      // 出错,event.target为null
      text: event.target.value,
      editCount: prev.editCount + 1,
    }));
  }, []);
  return (
    <div>
      <span>{input.editCount}</span>
      <input type="text" value={input.text} onChange={onChange} />
    </div>
  );
}

一个简单的办法当然是用变量将event.target.value提前保存起来。另外,我们也可以用React提供的event.persist()方法,它避免了event的释放。注意,它并不是原生DOM事件的方法。

event.stopPropagation失效

很多开发者反馈过stopPropagation失效的问题,他们明明在事件回调里调用了event.stopPropagation(),document上的监听却依然会触发。比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function App() {
  const boxRef = useRef(null);

  const onClickButton = useCallback((event/* React合成事件 */) => {
    console.log('点击button');
    event.stopPropagation();
  }, []);
  const onClickBox = useCallback((event/* 真实DOM事件 */) => {
    console.log('点击box');
  }, []);
  const onClickDoc = useCallback((event/* 真实DOM事件 */) => {
    console.log('点击document');
  }, []);

  // 正常在react里我们不会写原生的事件监听
  useEffect(() => {
    const box = boxRef.current;
    box?.addEventListener('click', onClickBox, false);
    document.addEventListener('click', onClickDoc, false);
    return () => {
      box?.removeEventListener('click', onClickBox, false);
      document.removeEventListener('click', onClickDoc, false);
    };
  }, [onClickBox, onClickDoc]);

  return (
    <div ref={boxRef}>
      <button onClick={onClickButton}>点击我</button>
    </div>
  );
}

正常来说,onClickButton里我们调用了event.stopPropagation(),事件是不会冒泡到box和document的。但实际上,onClickBoxonClickDoc都执行了打印。也就是说stopPropagation失效了。

前面已经说过,走完浏览器的捕获/冒泡阶段之后,才会执行合成事件的回调。所以,当我们点击真实的button时,事件冒泡到box,执行了onClickBox,然后继续冒泡到document,然后执行onClickButtononClickDoc这两个回调方法。但onClickButton在mount阶段就绑定,onClickDoc则在mounted之后才绑定,所以onClickButton先执行。

理解了这点,也就不难明白为什么stopPropagation失效,以及打印顺序为什么是:点击box => 点击button => 点击document。

这也是Atom编辑器团队想在应用里嵌套多个React版本时所遇到的问题,即无法阻止内层DOM树触发的event传播到外层DOM树。

为什么使用合成事件

  1. 浏览器兼容,统一行为,比如事件对象有统一的属性和方法,又比如,移除不想要的点击事件(Firefox右键点击会生成点击事件),再比如无论注册onMouseLeave还是onMouseOut都会映射成原生的mouseout事件;
  2. 多平台适配,ReactNative也能使用;
  3. 实现事件委托,避免大量创建事件监听;
  4. 事件池机制,避免频繁创建和销毁SyntheticEvent对象,释放过程将SyntheticEvent对象的大部分属性置为null,提升旧浏览器的性能。

React 17合成事件的变更

以下分析基于React v17.0.2版本。

事件委托

React 17合成事件的执行过程和React 16差不多,不过在构造Fiber树时会从current fiber往上找到root fiber,然后把listener绑定到root fiber对应的DOM元素上,也就是下面代码的rootNode

1
2
const rootNode = document.getElementById('root');
ReactDOM.render(<App />, rootNode);

事件的执行顺序如下图: react 17 event.png React事件的capture阶段在原生事件capture开始时执行,然后是原生事件的capture阶段和原生事件的bubble阶段,最后是React事件的bubble阶段。

事件委托到rootNode的好处:

  1. 可以在jquery里使用react不会有event.stopPropagation()失效的问题,可以同时存在多个react版本,对于想在旧项目里尝试新版本react的开发者来说应该是一个福音;
  2. 比较容易实现事件重放(replaying events)。

事件重放是React服务端渲染Selective Hydration的一项能力,简单来说就是注水之前用户的点击等操作在注水后能够重放。Dan有在twitter上发过一个demo,链接为https://twitter.com/dan_abramov/status/1200118229697486849

移除事件池

React 17移除了事件池机制,主要有两个原因:

  1. 虽然事件池能够在大量事件触发时减少内存分配,但在执行完事件之后释放和复用对象上做的事情有点多。这不一定能给现代浏览器带来收益,而且也没有其他人有类似的实践;
  2. 事件池的时候给开发者带来困惑,因为在异步方法里面使用event.target会得到null,需要通过event.persist()或者用变量暂存起来这样的方式解决,这不符合我们的习惯。

对于这个变更,开发者不用对代码进行任何的修改,因为event.persist变成了一个空方法,event.isPersistent()总是返回true

事件优先级

React 17开始,会根据事件的类型创建不同优先级的事件监听器listener,当真正触发DOM事件时,调度器Scheduler会按优先级调度listener的执行,执行过程包含合成事件的收集和按捕获->冒泡顺序执行onClick等事件回调。

不同事件的优先级:

  1. 离散事件(DiscreteEvent),非连续触发,包括click、input、keydown、focusin等,优先级为0;
  2. 用户阻塞事件(UserBlockingEvent),连续触发,包括drag、mousemove、touchmove、scroll等,优先级为1;
  3. 连续事件(ContinuousEvent),包括load、progress、playing、error等音视频相关的事件,优先级为2。

数值越大优先级越高,但实际上DiscreteEvent和UserBlockingEvent优先级相同,都对应调度器的UserBlockingPriority。

对齐浏览器

其他一些关于事件的变更,更好地贴近浏览器的表现:

  1. onScroll事件不再经过冒泡阶段,因为浏览器的scroll也不会冒泡;
  2. onFocus和onBlur事件映射到原生的focusin和focusout事件。

总结

React在浏览器原生事件的基础上实现了一套合成事件。

React 16.x及以前的合成事件:

  1. 事件委托到document;
  2. 部分事件还是会绑定到当前元素;
  3. 存在React事件和原生事件的映射关系,比如onMouseLeave会映射成原生的mouseout事件;
  4. 事件池机制。

React 17的合成事件:

  1. 事件委托到root;
  2. React capture阶段的合成事件提前到原生事件capture阶段执行;
  3. 移除事件池机制;
  4. 事件有优先级。

参考资料

  1. https://legacy.reactjs.org/blog/2020/08/10/react-v17-rc.html
  2. https://legacy.reactjs.org/docs/events.html
  3. https://github.com/facebook/react/pull/18216
  4. https://zhuanlan.zhihu.com/p/166625150
本文由作者按照 CC BY-NC-ND 3.0 进行授权

React项目里我们不用担心XSS攻击吗?

带你了解JS引擎的性能优化手段:Inline Caches