React17 事件系统 更改 & 16 之前事件系统介绍

3,987 阅读6分钟

一. Breaking change

1. 事件委托不再挂到 document 上

之前多版本并存的主要问题在于React 事件系统默认的委托机制,出于性能考虑,React 只会给document挂上事件监听,DOM 事件触发后冒泡到document,React 找到对应的组件,造一个 React 事件(SyntheticEvent)出来,并按组件树模拟一遍事件冒泡(此时原生 DOM 事件早已冒出document了)。
因此,不同版本的 React 组件嵌套使用时,e.stopPropagation()无法正常工作(两个不同版本的事件系统是独立的,都到document已经太晚了
(If a nested tree has stopped propagation of an event, the outer tree would still receive it).

为了解决这个问题,React 17 不再往document上挂事件委托,而是挂到 DOM 容器上:

compare.png

react 17 delegation

const rootNode = document.getElementById('root');
// 以为 render 为例
ReactDOM.render(<App />, rootNode);
// Portals 也一样
// ReactDOM.createPortal(<App />, rootNode)
// React 16 事件委托(挂到 document 上)
document.addEventListener()
// React 17 事件委托(挂到 DOM container 上)
rootNode.addEventListener()

二. React 16 事件系统

1. React事件系统

1-1. React 基于 Virtual DOM 实现了一个SyntheticEvent(合成事件)层,我们所定义的事件处理器会接收到一个SyntheticEvent对象的实例,同样支持事件的冒泡机制,我们可以使用stopPropagation()和preventDefault()来中断它。

1-2. 所有事件都自动绑定到最外层上(document)

2. 合成事件绑定机制

在 React 底层,主要对合成事件做了两件事:事件委派和自动绑定。

2.1 事件委派

React并不会把事件处理函数直接绑定到真实的节点上,而是把所有事件绑定到结构的最外层,使用一个统一的事件监听器,这个事件监听器上维持了一个映射来保存所有组件内部的事件监听和处理函数。

当组件挂载或卸载时,只是在这个统一的事件监听器上插入或删除一些对象;当事件发生时,首先被这个统一的事件监听器处理,然后在映射里找到真正的事件处理函数并调用。

这样做简化了事件处理和回收机制,效率也有很大提升。

2.2 自动绑定

在React 组件中,每个方法的上下文都会指向该组件的实例,即自动绑定this为当前组件。而且 React 还会对这种引用进行缓存,以达到 CPU 和内存的最优化。

3. 在React中使用原生事件

React 提供了完备的生命周期方法,其中componentDidMount会在组件已经完成安装并且在浏览器中存在真实的 DOM 后调用,此时我们就可以完成原生事件的绑定。 但是React不会自动管理原生事件,所以需要你在卸载组件的时候注销掉原生事件。

4. 合成事件与原生事件混用
  • 不要将合成事件与原生事件混用

  • 通过e.target判断来避免

      用reactEvent.nativeEvent.stopPropagation()来阻止冒泡是不行的。阻止 React 事件冒泡的行为只能用于 React 合成事件系统中,且没办法阻止原生事件的冒泡。
      反之,在原生事件中的阻止冒泡行为,却可以阻止 React 合成事件的传播。
    
5. React stopPropagation 与 stopImmediatePropagation

通过 React 绑定的事件,其回调函数中的 event 对象,是经过 React 合成的 SyntheticEvent,与原生的 DOM 事件的 event 不是一回事。准确地说,在 React 中,e.nativeEvent 才是原生 DOM 事件的那个 event。

React 合成事件与原生事件执行顺序图 3853478932-5a9ff2f3efa39_fix732.png

  • DOM 事件冒泡到document上才会触发React的合成事件,所以React 合成事件对象的e.stopPropagation,只能阻止 React 模拟的事件冒泡,并不能阻止真实的 DOM 事件冒泡
  • DOM 事件的阻止冒泡也可以阻止合成事件原因是DOM 事件的阻止冒泡使事件不会传播到document上
  • 当合成事件和DOM 事件 都绑定在document上的时候,React的处理是合成事件应该是先放进去的所以会先触发,在这种情况下,原生事件对象的 stopImmediatePropagation能做到阻止进一步触发document DOM事件

3262710300-5b546f588d9c2_fix732.png

若想阻止合成事件与除最外层document上的原生事件上的冒泡,通过判断e.target来避免,代码如下:

document.body.addEventListener('click', e => {   
    if (e.target && e.target.matches('div.code')) {  
      return;    
    }    
 }
 
6. 源码

事件注册即在 document 节点,将 React 事件转化为 DOM 原生事件,并注册回调。

6.1 注册
// enqueuePutListener 负责事件注册。
// inst:注册事件的 React 组件实例
// registrationName:React 事件,如:onClick、onChange
// listener:和事件绑定的 React 回调方法,如:handleClick、handleChange
// transaction:React 事务流,不懂没关系,不太影响对事件系统的理解
function enqueuePutListener(inst, registrationName, listener, transaction) {
    ... ...
   // doc 为找到的 document 节点
    var doc = isDocumentFragment ? containerInfo._node : containerInfo._ownerDocument;
    // 事件注册
    listenTo(registrationName, doc);
    // 事件存储,之后会讲到,即存储事件回调方法
    transaction.getReactMountReady().enqueue(putListener, {
        inst: inst,
        registrationName: registrationName,
        listener: listener
    });
}
  

如何在 document 上绑定 DOM 原生事件

// 事件注册
// registrationName:React 事件名,如:onClick、onChange
// contentDocumentHandle:要将事件绑定到的 DOM 节点
listenTo: function (registrationName, contentDocumentHandle) {
    // document
    var mountAt = contentDocumentHandle;      
    // React 事件和绑定在根节点的 topEvent 的转化关系,如:onClick -> topClick
    var dependencies = EventPluginRegistry.registrationNameDependencies[registrationName];
    
    for (var i = 0; i < dependencies.length; i++){
        // 内部有大量判断浏览器兼容等的步骤,提取一下核心代码
        var dependency = dependencies[i];
        
        // topEvent 和原生 DOM 事件的转化关系
        if (topEventMapping.hasOwnProperty(dependency)) {
            // 三个参数为 topEvent、原生 DOM Event、Document
            // 将事件绑定到冒泡阶段
            trapBubbledEvent(dependency, topEventMapping[dependency], mountAt);
        }
    }
}

将事件绑定到冒泡阶段的具体代码:

// 三个参数为 topEvent、原生 DOM Event、Document(挂载节点)
trapBubbledEvent: function (topLevelType, handlerBaseName, element) {
    if (!element) {
        return null;
    }
    return EventListener.listen(element, handlerBaseName, ReactEventListener.dispatchEvent.bind(null, topLevelType));
}

// 三个参数为 Document(挂载节点)、原生 DOM Event、事件绑定函数
listen: function listen(target, eventType, callback) {
    // 去除浏览器兼容部分,留下核心后
    target.addEventListener(eventType, callback, false);
    // 返回一个解绑的函数
    return {
        remove: function remove() {
            target.removeEventListener(eventType, callback, false);
        }
    }
}
6.2 存储

事件注册之后,还需要将事件绑定的回调函数存储下来。这样,在触发事件后才能去寻找相应回调来触发。在一开始的代码中,我们已经看到,是使用 putListener 方法来进行事件回调存储。

// inst:注册事件的 React 组件实例
// registrationName:React 事件,如:onClick、onChange
// listener:和事件绑定的 React 回调方法,如:handleClick、handleChange
putListener: function (inst, registrationName, listener) {
    // 核心代码如下
    // 生成每个组件实例唯一的标识符 key
    var key = getDictionaryKey(inst);
    // 获取某种 React 事件在回调存储银行中的对象
    var bankForRegistrationName = listenerBank[registrationName] || (listenerBank[registrationName] = {});
    bankForRegistrationName[key] = listener;
}
6.3 执行

每次触发事件都会执行根节点上 addEventListener 注册的回调,也就是 ReactEventListener.dispatchEvent 方法,事件分发入口函数。该函数的主要业务逻辑如下:

找到事件触发的 DOM 和 React Component 从该 React Component,调用 findParent 方法,遍历得到所有父组件,存在数组中。 从该组件直到最后一个父组件,根据之前事件存储,用 React 事件名 + 组件 key,找到对应绑定回调方法,执行,详细过程为:

根据 DOM 事件构造 React 合成事件。 将合成事件放入队列。 批处理队列中的事件(包含之前未处理完的,先入先处理) React合成事件的冒泡并不是真的冒泡,而是节点的遍历。