0

huamu_y的个人空间

 2 years ago
source link: https://my.oschina.net/jill1231/blog/5057885
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

一、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源码的基础上,我们来看看事件的注册过程:

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

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

listenerMap.has(topLevelType)

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

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

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

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

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

  • 循环收集符合条件(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()


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK