React 事件系统

一、DOM 事件流

在浏览器中,我们通过事件监听来实现 JS 和 HTML 之间的交互。一个页面往往会被绑定许许多多的事件,而页面接收事件的顺序,就是事件流。它类似于蹦床,从高处下落,触达蹦床后再弹起,整个过程呈一个V字形。若按W3C标准,一个事件的传播过程要经过三个阶段

1、DOM 事件流的三个阶段

  • 事件捕获阶段
    事件从最外层的元素开始“穿梭”,逐层“穿梭”,直到目标元素,也就是真正触发事件的元素

  • 目标阶段
    事件被目标元素所接收

  • 事件冒泡阶段
    事件被“回弹”,沿着来时的路“逆流而上”,逐层往上

11f2a4f02636e6d6undefined

2、事件委托

假设我们有这么一个场景:在拥有1000个li元素的列表上,点击每一个li输出其对应的文本内容

很直观的一个思路:让每个li元素去监听一个点击动作,但这样重复的代码不够优雅,开销也蛮大。若利用 DOM 事件流的事件冒泡特性,我们可以这么做:把多个子元素的同一类型的监听逻辑合并到父元素上,通过一个监听函数来管理行为,即通过事件对象中的target属性,获取到真正触发事件的元素,这也是所谓的事件委托

let ul = document.getElementsByTagName('ul')

ul.addEventListener('click', function(e){
  // e.target属性指的是触发事件的具体目标,记录着事件的源头
  console.log(e.target.innerHTML)
})

通过事件委托处理,可减少内存开销、简化注册步骤,从而提高开发效率。这也给了react 16灵感,实现对所有的事件的中心化管理。

二、React 事件系统

当事件在具体的DOM节点上被触发后,最终都会冒泡到document上(除了少数特殊的不可冒泡的事件,例如媒体类型的事件外),document上所绑定的统一事件处理程序会将事件分发到具体的组件实例

React 16 及之前版本中的事件系统

在分发事件之前,React 首先会对事件进行包装,把原生DOM事件包装成合成事件

1、React合成事件

React 16 及之前版本中,React自定义的合成事件主要在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的,稳定的,与DOM原生事件相同的事件接口,同时它保存了原生DOM事件的引用。当开发者需要访问原生DOM事件对象时,可通过合成事件对象的e.nativeEvent属性获取到它

2、React事件系统的工作流

说到事件系统,就有事件的绑定和触发两个关键动作,其中事件的绑定是在挂载阶段里的completeWork函数完成的。completeWork函数内部做了三个关键动作:

  • 创建 DOM 节点
  • DOM 节点插入到 DOM 树中
  • DOM 节点设置属性 - 该环节会遍历 FiberNodeprops key,当遍历到事件相关的 props 时,便会触发事件的注册链路

事件绑定

react16源码的基础上,我们来看看事件的注册过程:

891d7dcf79cb5e1cundefined

其中,源码中有一段判断逻辑值得我们关注

// listenerMap: 记录当前document已经监听了哪些事件
// topLevelType: 事件的类型

listenerMap.has(topLevelType)

若事件系统识别到 listenerMap.has(topLevelType)true,则说明该函数 document 已经监听过了,直接跳过。因此,即便我们在 react项目中多次调用对同一个事件的监听,也只会在 document 上触发一次注册。

Q: 为什么针对同一个事件,即便可能会存在多个回调,document也只需注册一次监听?

A:react 最终注册到 document 上的并不是某一个DOM节点上对应的具体回调逻辑,而是一个统一的事件分发函数dispatchEvent

事件触发

同样,在react16源码的基础上,我们来看看事件的触发过程:

11f9c4aa13be58d7undefined

其中,事件回调的收集与执行值得我们关注,它主要做了以下三件事:

  • 循环收集符合条件(DOM元素对应的Fiber节点)的父节点,存进path数组
  • 模拟事件在捕获阶段的传播顺序,收集捕获阶段相关节点对应的回调与实例
  • 模拟事件在冒泡阶段的传播顺序,收集冒泡阶段相关节点对应的回调与实例

接下来,我们来看看源码是如何巧妙的模拟出完整的DOM 事件流

function traverseTwoPhase(inst, fn, arg) {

 // 定义一个 path 数组:子节点在前,祖先节点在后
 var path = [];

 while (inst) {
   // 将当前节点收集进 path 数组
   path.push(inst);
   // 向上收集 tag===HostComponent 的父节点
   inst = getParent(inst);
 }

 var i;
 
 // 模拟捕获阶段:从后往前,收集 path 数组中会参与捕获过程的节点与对应回调
 for (i = path.length; i-- > 0;) {
   // fn 函数对节点进行检查,若回调不为空,则将实例收集到 SyntheticEvent._dispatchInstances,事件回调则被收集到 SyntheticEvent._dispatchListeners
   fn(path[i], 'captured', arg);
 }

 // 模拟冒泡阶段:从前往后,收集 path 数组中会参与冒泡过程的节点与对应回调
 for (i = 0; i < path.length; i++) {
   // 同上  
   fn(path[i], 'bubbled', arg);
 }

}

traverseTwoPhase 函数主要做了三件事:

重点强调的是:当前事件对应的合成事件实例有且只有一个,因此在模拟捕获和冒泡两个过程,收集到的实例会被存入同一个SyntheticEvent._dispatchInstances,同样,收集到的事件回调也会被存入同一个SyntheticEvent._dispatchListeners。因此,只需要按顺序执行SyntheticEvent._dispatchListeners 数组中的回调函数,就能模拟出完整的DOM 事件流,即“捕获-目标-冒泡”三个阶段

三、React16 事件系统的设计动机是什么?

1、React 官方说明过的一点是:合成事件符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的底层兼容问题,可以专注于业务逻辑的开发

2、自研事件系统使 React 牢牢把握住了事件处理的主动权,能够从很大程度上干预事件的表现,使其符合自身的需求,毕竟原生讲究的就是个通用性。而 React 想要的则是“量体裁衣”。

四、React16 事件系统的不足

GitHub issue里有一个这样的Bug

提问者试图在input元素的React事件函数中阻止冒泡,但事实并没有如愿,每次点击input的时候,事件还是会被冒泡到document上去。对此,他得到的回复是这样的:

React通过将所有事件冒泡到document来实现对事件的中心化管控,而document是整个文档树的根节点,操作它带来的影响范围着实太大。

提问者在handleClick这个React事件函数中阻止了冒泡,但这只能保证该事件对应的合成事件在React事件体系下的冒泡被阻止了,即React不会为这个合成事件 模拟冒泡效果,并不能阻止原生DOM事件的冒泡,因此安装在document上的事件监听器一定会被触发

且不说document中心化管控这个设定给开发者带来了多大的限制,单看document是一个全局的概念,而组件只是全局的一个部分,便能多少预感到其中的风险。

五、React17的改进

1、放弃利用document来做事件的中心化管控

React 17正面解决了这个问题:事件的中心化管控不会再全部依赖document,管控相关的逻辑被转移到了每个React组件自己的容器DOM节点上。

React 16 及之前版本中,React 会对大多数事件进行 document.addEventListener() 操作。React 17 开始会通过调用 rootNode.addEventListener() 来代替。

放弃利用document来做事件的中心化管控

这样一来,React组件就能够各玩各的,再也无法对全局的事件流造成影响

2、放弃事件池

React 17之前,合成事件对象会被放进一个叫作:“事件池”的地方统一管理。其目的是为了实现事件的复用,进而提高性能。即当所有事件处理函数被调用之后,其所有属性都会被置空,换句话说:事件逻辑一旦执行完毕,开发者就拿不到事件对象了

有个官方的例子 如下:

function handleChange(e) {
  // This won't work because the event object gets reused.
  setTimeout(() => {
    console.log(e.target.value); // Too late!
  }, 100);
}

异步执行的setTimeout回调会在handleChange这个事件处理函数执行完毕后执行,因此它拿不到想要的那个事件对象,如果你需要在事件处理函数运行之后获取事件对象的属性,你需要调用 e.persist()