Skip to content

React合成事件

一前言

我们来好好聊一下 React 的事件系统。我想先问一个问题,你觉得 React 事件系统对开发者来说重要吗?

事实上,前端应用因为离用户最近,所以会有很多交互逻辑,就会有很多事件与之绑定。因此,学习 React 事件系统更有利于开发者合理处理这些事件。

通过本章节的学习,你将收获 React 事件系统流程原理,从而解决面试中关于 React 事件的诸多问题。

请带着问题去阅读,效果更佳:

  • React 为什么有自己的事件系统?
  • 什么是事件合成 ?
  • 如何实现的批量更新?
  • 事件系统如何模拟冒泡和捕获阶段?
  • 如何通过 dom 元素找到与之匹配的fiber?
  • 为什么不能用 return false 来阻止事件的默认行为?
  • 事件是绑定在真实的dom上吗?如何不是绑定在哪里?
  • V17 对事件系统有哪些改变?

首先,我要大胆地说,在 React 应用中,我们所看到的React事件都是‘假’的! 可能有的同学对我说的丈二和尚摸不着头脑,不过不要紧,我会一步步说它到底假在哪里?你要知道:

  • 1 给元素绑定的事件,不是真正的事件处理函数。
  • 2 在冒泡/捕获阶段绑定的事件,也不是在冒泡/捕获阶段执行的。
  • 3 甚至在事件处理函数中拿到的事件源 e ,也不是真正的事件源 e 。

React 为什么要写出一套自己的事件系统呢?

首先,对于不同的浏览器,对事件存在不同的兼容性,React 想实现一个兼容全浏览器的框架, 为了实现这个目标就需要创建一个兼容全浏览器的事件系统,以此抹平不同浏览器的差异。

其次,v17 之前 React 事件都是绑定在 document 上,v17 之后 React 把事件绑定在应用对应的容器 container 上,将事件绑定在同一容器统一管理,防止很多事件直接绑定在原生的 DOM 元素上。造成一些不可控的情况。由于不是绑定在真实的 DOM 上,所以 React 需要模拟一套事件流:事件捕获-> 事件源 -> 事件冒泡,也包括重写一下事件源对象 event 。

最后,这种事件系统,大部分处理逻辑都在底层处理了,这对后期的 ssr 和跨端支持度很高。

本章节涉及到事件原理均为 v16.13.1 ,对于v17以及未来版本放弃的功能,这里会一笔带过。

二独特的事件处理

冒泡阶段和捕获阶段

js
export default function Index(){
    const handleClick=()=>{ console.log('模拟冒泡阶段执行') } 
    const handleClickCapture = ()=>{ console.log('模拟捕获阶段执行') }
    return <div>
        <button onClick={ handleClick  } onClickCapture={ handleClickCapture }  >点击</button>
    </div>
}
  • 冒泡阶段:开发者正常给 React 绑定的事件比如 onClick,onChange,默认会在模拟冒泡阶段执行。
  • 捕获阶段:如果想要在捕获阶段执行可以将事件后面加上 Capture 后缀,比如 onClickCapture,onChangeCapture。

阻止冒泡

React 中如果想要阻止事件向上冒泡,可以用 e.stopPropagation()

js
export default function Index(){
    const handleClick=(e)=> {
        e.stopPropagation() /* 阻止事件冒泡,handleFatherClick 事件讲不在触发 */
    }
    const handleFatherClick=()=> console.log('冒泡到父级')
    return <div onClick={ handleFatherClick } >
        <div onClick={ handleClick } >点击</div>
    </div>
}
  • React 阻止冒泡和原生事件中的写法差不多,当如上 handleClick上 阻止冒泡,父级元素的 handleFatherClick 将不再执行,但是底层原理完全不同,接下来会讲到其功能实现。

阻止默认行为

React 阻止默认行为和原生的事件也有一些区别。

原生事件: e.preventDefault()return false 可以用来阻止事件默认行为,由于在 React 中给元素的事件并不是真正的事件处理函数。所以导致 return false 方法在 React 应用中完全失去了作用。

React事件 在React应用中,可以用 e.preventDefault() 阻止事件默认行为,这个方法并非是原生事件的 preventDefault ,由于 React 事件源 e 也是独立组建的,所以 preventDefault 也是单独处理的。

三 事件合成

React 事件系统可分为三个部分:

  • 第一个部分是事件合成系统,初始化会注册不同的事件插件。
  • 第二个就是在一次渲染过程中,对事件标签中事件的收集,向 container 注册事件。
  • 第三个就是一次用户交互,事件触发,到事件执行一系列过程。

事件合成概念

首先需要弄清楚什么叫事件合成呢?

比如在整个 React 应用中只绑定一个事件:

js
export default function Index(){
  const handleClick = () => {}
  return <div >
     <button onClick={ handleClick } >点击</button>
  </div>
}

上面在 button 元素绑定的事件中,没有找到 handleClick 事件。但是在 document 上绑定一个 onclick 事件,如下:

1.jpg

于是如下将应用中再添加一个 input 并绑定一个 onChange 事件:

js
export default function Index(){
  const handleClick = () => {}
  const handleChange =() => {}
  return <div >
     <input onChange={ handleChange }  />
     <button onClick={ handleClick } >点击</button>
  </div>
}

在 input上还是没有找到绑定的事件 handleChange ,但是 document 的事件如下:

2.jpg

多了 blur,change ,focus ,keydown,keyup 等事件。

如上可以作出的总结是:

  • React 的事件不是绑定在元素上的,而是统一绑定在顶部容器上,在 v17 之前是绑定在 document 上的,在 v17 改成了 app 容器上。这样更利于一个 html 下存在多个应用(微前端)。
  • 绑定事件并不是一次性绑定所有事件,比如发现了 onClick 事件,就会绑定 click 事件,比如发现 onChange 事件,会绑定 [blur,change ,focus ,keydown,keyup] 多个事件。
  • React 事件合成的概念:React 应用中,元素绑定的事件并不是原生事件,而是React 合成的事件,比如 onClick 是由 click 合成,onChange 是由 blur ,change ,focus 等多个事件合成。

事件插件机制

React 有一种事件插件机制,比如上述 onClick 和 onChange ,会有不同的事件插件 SimpleEventPlugin ,ChangeEventPlugin 处理,先不必关心事件插件做了些什么,只需要先记住两个对象。这个对于后续的了解很有帮助。

第一个 registrationNameModules :

js
const registrationNameModules = {
    onBlur: SimpleEventPlugin,
    onClick: SimpleEventPlugin,
    onClickCapture: SimpleEventPlugin,
    onChange: ChangeEventPlugin,
    onChangeCapture: ChangeEventPlugin,
    onMouseEnter: EnterLeaveEventPlugin,
    onMouseLeave: EnterLeaveEventPlugin,
    ...
}

registrationNameModules 记录了 React 事件(比如 onBlur )和与之对应的处理插件的映射,比如上述的 onClick ,就会用 SimpleEventPlugin 插件处理,onChange 就会用 ChangeEventPlugin 处理。应用于事件触发阶段,根据不同事件使用不同的插件。

|--------问与答---------| 问:为什么要用不同的事件插件处理不同的 React 事件?

答:首先对于不同的事件,有不同的处理逻辑;对应的事件源对象也有所不同,React 的事件和事件源是自己合成的,所以对于不同事件需要不同的事件插件处理。

|--------end---------|

第二个registrationNameDependencies

js
{
    onBlur: ['blur'],
    onClick: ['click'],
    onClickCapture: ['click'],
    onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
    onMouseEnter: ['mouseout', 'mouseover'],
    onMouseLeave: ['mouseout', 'mouseover'],
    ...
}

这个对象保存了 React 事件和原生事件对应关系,这就解释了为什么上述只写了一个 onChange ,会有很多原生事件绑定在 document 上。在事件绑定阶段,如果发现有 React 事件,比如 onChange ,就会找到对应的原生事件数组,逐一绑定。

四 事件绑定

接下来重点研究一下事件绑定阶段,所谓事件绑定,就是在 React 处理 props 时候,如果遇到事件比如 onClick ,就会通过 addEventListener 注册原生事件,讲解事件注册之前先来想一个问题,还是上述的 demo ,给元素绑定的事件 handleClick ,handleChange ,最后去了哪里呢?

js
export default function Index(){
  const handleClick = () => console.log('点击事件')
  const handleChange =() => console.log('change事件)
  return <div >
     <input onChange={ handleChange }  />
     <button onClick={ handleClick } >点击</button>
  </div>
}
  • 对于如上结构,最后 onChange 和 onClick 会保存在对应 DOM 元素类型 fiber 对象( hostComponent )的 memoizedProps 属性上,如上结构会变成这样。

4.jpg

接下来就是 React 根据事件注册事件监听器。

react-dom/src/client/ReactDOMComponent.js

js
function diffProperties(){
    /* 判断当前的 propKey 是不是 React合成事件 */
    if(registrationNameModules.hasOwnProperty(propKey)){
         /* 这里多个函数简化了,如果是合成事件, 传入成事件名称 onClick ,向document注册事件  */
         legacyListenToEvent(registrationName, document);
    }
}

diffProperties 函数在 diff props 如果发现是合成事件( onClick ) 就会调用 legacyListenToEvent 函数。注册事件监听器。接下来看一下 legacyListenToEvent 是如何注册事件的。

react-dom/src/events/DOMLegacyEventPluginSystem.js

js
function legacyListenToEvent(registrationNamemountAt){
   const dependencies = registrationNameDependencies[registrationName]; // 根据 onClick 获取  onClick 依赖的事件数组 [ 'click' ]。
    for (let i = 0; i < dependencies.length; i++) {
    const dependency = dependencies[i];
    //  addEventListener 绑定事件监听器
    ...
  }
}
  • 这个就是应用上述 registrationNameDependencies 对 React 合成事件,分别绑定原生事件的事件监听器。比如发现是 onChange ,那么取出 ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'] 遍历绑定。

那么有一个疑问,绑定在 document 的事件处理函数是如上写的handleChange,handleClick 吗?

答案是否定的,绑定在 document 的事件,是 React 统一的事件处理函数 dispatchEvent ,React 需要一个统一流程去代理事件逻辑,包括 React 批量更新等逻辑。

只要是 React 事件触发,首先执行的就是 dispatchEvent ,那么有的同学会问,dispatchEvent 是如何知道是什么事件触发的呢?实际在注册的时候,就已经通过 bind ,把参数绑定给 dispatchEvent 了。

比如绑定 click 事件

js
const listener = dispatchEvent.bind(null,'click',eventSystemFlags,document) 
/* TODO: 重要, 这里进行真正的事件绑定。*/
document.addEventListener('click',listener,false)

五 事件触发

一次点击事件

为了让大家更清楚了解事件触发的流程,假设 DOM 结构是如下这样的:

js
export default function Index(){
    const handleClick1 = () => console.log(1)
    const handleClick2 = () => console.log(2)
    const handleClick3 = () => console.log(3)
    const handleClick4 = () => console.log(4)
    return <div onClick={ handleClick3 }  onClickCapture={ handleClick4 }  >
        <button onClick={ handleClick1 }  onClickCapture={ handleClick2 }  >点击</button>
    </div>
}

如果上述点击按钮,触发点击事件,那么在 React 系统中,整个流程会是这个样子的:

第一步:批量更新

首先上面讲到执行 dispatchEvent ,dispatchEvent 执行会传入真实的事件源 button 元素本身。通过元素可以找到 button 对应的 fiber ,fiber 和原生 DOM 之间是如何建立起联系的呢?

React 在初始化真实 DOM 的时候,用一个随机的 key internalInstanceKey 指针指向了当前 DOM 对应的 fiber 对象,fiber 对象用 stateNode 指向了当前的 DOM 元素。

D3A29E95-F235-417B-951C-A15AB2ABA391.jpg

接下来就是批量更新环节,批量更新在 state 章节已经讲过,这里就不说了,还没掌握的同学可以回去温习一下。

react-dom/src/events/ReactDOMUpdateBatching.js

js
export function batchedEventUpdates(fn,a){
    isBatchingEventUpdates = true; //打开批量更新开关
    try{
       fn(a)  // 事件在这里执行
    }finally{
        isBatchingEventUpdates = false //关闭批量更新开关
    }
}

第一阶段模型:

5.jpg

第二步:合成事件源

接下来会通过 onClick 找到对应的处理插件 SimpleEventPlugin ,合成新的事件源 e ,里面包含了 preventDefault 和 stopPropagation 等方法。

第二阶段模型:

6.jpg

第三步:形成事件执行队列

在第一步通过原生 DOM 获取到对应的 fiber ,接着会从这个 fiber 向上遍历,遇到元素类型 fiber ,就会收集事件,用一个数组收集事件:

  • 如果遇到捕获阶段事件 onClickCapture ,就会 unshift 放在数组前面。以此模拟事件捕获阶段。
  • 如果遇到冒泡阶段事件 onClick ,就会 push 到数组后面,模拟事件冒泡阶段。
  • 一直收集到最顶端 app ,形成执行队列,在接下来阶段,依次执行队列里面的函数。
js
 while (instance !== null) {
    const {stateNode, tag} = instance;
    if (tag === HostComponent && stateNode !== null) { /* DOM 元素 */
        const currentTarget = stateNode;
        if (captured !== null) { /* 事件捕获 */
            /* 在事件捕获阶段,真正的事件处理函数 */
            const captureListener = getListener(instance, captured); // onClickCapture
            if (captureListener != null) {
            /* 对应发生在事件捕获阶段的处理函数,逻辑是将执行函数unshift添加到队列的最前面 */
                dispatchListeners.unshift(captureListener);
                
            }
        }
        if (bubbled !== null) { /* 事件冒泡 */
            /* 事件冒泡阶段,真正的事件处理函数,逻辑是将执行函数push到执行队列的最后面 */
            const bubbleListener = getListener(instance, bubbled); // 
            if (bubbleListener != null) {
                dispatchListeners.push(bubbleListener); // onClick
            }
        }
    }
    instance = instance.return;
}

那么如上点击一次按钮,4个事件执行顺序是这样的:

  • 首先第一次收集是在 button 上,handleClick1 冒泡事件 push 处理,handleClick2 捕获事件 unshift 处理。形成结构 [ handleClick2 , handleClick1 ]
  • 然后接着向上收集,遇到父级,收集父级 div 上的事件,handleClick3 冒泡事件 push 处理,handleClick4 捕获事件 unshift 处理。[handleClick4, handleClick2 , handleClick1,handleClick3 ]
  • 依次执行数组里面的事件,所以打印 4 2 1 3。

第三阶段模型:

7.jpg

React如何模拟阻止事件冒泡

那么 React 是如何阻止事件冒泡的呢。来看一下事件队列是怎么执行的。

legacy-events/EventBatching.js

js
function runEventsInBatch(){
    const dispatchListeners = event._dispatchListeners;
    if (Array.isArray(dispatchListeners)) {
    for (let i = 0; i < dispatchListeners.length; i++) {
      if (event.isPropagationStopped()) { /* 判断是否已经阻止事件冒泡 */
        break;
      }    
      dispatchListeners[i](event) /* 执行真正的处理函数 及handleClick1... */
    }
  }
}

对于上述队列 [handleClick4, handleClick2 , handleClick1, handleClick3 ]

  • 假设在上述队列中,handleClick2 中调用 e.stopPropagation(),那么事件源里将有状态证明此次事件已经停止冒泡,那么下次遍历的时候, event.isPropagationStopped() 就会返回 true ,所以跳出循环,handleClick1, handleClick3 将不再执行,模拟了阻止事件冒泡的过程。

六 总结

本章节把整个 React 事件系统主要流程讲了一遍,v17 版本相比 v16 改了一些东西,不过大体思路相差不大,希望看完能理解如下知识点,这在面试中是常考的:

  • 1 什么是事件合成。
  • 2 如何模拟事件捕获和事件冒泡阶段。
  • 3 如何处理事件源对象。
  • 4 一次点击到事件执行都发生了什么?

前端知识体系 · wcrane