15-原理篇-事件原理
课程
1
写给想要进阶的你
已学完
学习时长: 4分28秒
2
基础篇-认识 jsx
已学完
学习时长: 40分51秒
3
基础篇-起源 Component
已学完
学习时长: 32分50秒
4
基础篇-玄学 state
已学完
学习时长: 33分37秒
5
基础篇-深入 props
已学完
学习时长: 43分58秒
6
基础篇-理解 lifeCycle
学习时长: 72分46秒
7
基础篇-多功能 Ref
已学完
学习时长: 71分17秒
8
基础篇-提供者 context
已学完
学习时长: 53分16秒
9
基础篇-模块化 css
学习时长: 35分20秒
10
基础篇-高阶组件
学习时长: 43分29秒
11
优化篇-渲染控制
学习时长: 50分4秒
12
优化篇-渲染调优
学习时长: 35分8秒
13
优化篇-处理海量数据
学习时长: 28分1秒
14
优化篇-细节处理(持续)
学习时长: 32分19秒
15
原理篇-事件原理
学习时长: 30分48秒
16
原理篇-调度与时间片
学习时长: 23分47秒
17
原理篇- 调和与 fiber
学习时长: 35分54秒
18
原理篇-Hooks 原理
已学完
学习时长: 34分34秒
19
生态篇-React-router
学习时长: 50分9秒
20
生态篇-React-redux
学习时长: 50分34秒
21
生态篇-React-mobx
学习时长: 65分3秒
22
实践篇-实现mini-Router
学习时长: 37分19秒
23
实践篇-表单验证上
学习时长: 41分49秒
24
实践篇-表单验证下
学习时长: 46分31秒
25
实践篇-自定义弹窗
学习时长: 35分4秒
26
实践篇-自定义 Hooks 设计(持续)
学习时长: 32分13秒
27
实践篇-自定义 Hooks 实践(持续)
学习时长: 58分47秒
28
总结篇-如何有效阅读源码
学习时长: 7分18秒
29
原理篇-Context原理
学习时长: 28分27秒
30
原理篇-beginWork和render全流程
学习时长: 38分16秒
31
V18特性篇-useMutableSource(已被取缔)
学习时长: 28分37秒
32
V18特性篇-transition
学习时长: 38分15秒
33
原理篇-更新流程:进入调度任务
学习时长: 30分20秒
34
v18特性篇-concurrent 下的 state更新流程
学习时长: 29分27秒
35
v18特性篇-订阅外部数据源
学习时长: 16分53秒
36
原理篇-v18commit全流程
学习时长: 45分40秒
37
v18新特性-Offscreen
学习时长: 1秒
38
实践篇-设计并实现 keepalive 功能
学习时长: 71分13秒
juejin_logo copyCreated with Sketch.

一前言

本章节,我们来好好聊一下 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以及未来版本放弃的功能,这里会一笔带过。

二独特的事件处理

冒泡阶段和捕获阶段

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()

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 应用中只绑定一个事件:

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

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

1.jpg

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

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 :

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

{
    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 ,最后去了哪里呢?

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

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

function legacyListenToEvent(registrationName,mountAt){
   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 事件

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

五 事件触发

一次点击事件

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

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

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 ,形成执行队列,在接下来阶段,依次执行队列里面的函数。
 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

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 一次点击到事件执行都发生了什么?
留言
Ctrl + Enter
全部评论(53)
codeNoBB的头像
删除
前端工程师
你好,我这遇到点问题。文中说在 button 元素绑定的事件中,没有找到 handleClick 事件。但是在 document 上绑定一个 onclick 事件。但我这可以看到button元素上绑定的click事件,这是为什么呢???
点赞
回复
在工作的L的头像
删除
前端 @ 碳衡科技
查看某个元素全部绑定事件是怎么操作呀,像图中的一样
点赞
回复
guoshiping的头像
删除
想问一下,react是如何做到阻止事件默认行为的 ?调用合成事件的e.preventDefault()为什么能阻止默认行为?
点赞
1
删除
因为事件源event也是react重新写的,里面有阻止冒泡和阻止默认行为的方法。
点赞
回复
lleung的头像
删除
字节跳动
太强了!!!!
1
回复
柳杉的头像
删除
微信公众号 @ 柳杉前端
打卡,这里看了两遍,看懂一些,谢谢作者的分享!(liush.top)
点赞
回复
戬翀的头像
删除
前端开发
最后一部分阻止事件冒泡那里,如果在handleClick4 中调用了e.stopPropagation(),连 handleClick2 也不会执行?e.stopPropagation() 实际上连捕获时间也阻止了?
点赞
回复
BrownWhite的头像
删除
第三阶段模型图加载不出来??
点赞
回复
teal_front的头像
删除
web前端开发
可以通过浏览器的dispatchEvent的方式去触发绑定的click事件吗?
1
回复
Jinyang的头像
删除
前端开发 @ 上海佛系公司
看第二遍,终于理解了[赞]
点赞
回复
八倍镜的头像
删除
高级前端工程师
nativeEvent这个怎么来的啊
点赞
回复
jimi的头像
删除
前端开发
赞,目前知道的最深入的解析
点赞
回复
JamesBondy的头像
删除
前端
物超所值,没有之一。这一些列太给力了~
点赞
回复
IsLand的头像
删除
打卡~
点赞
回复
HelloGGX的头像
删除
ggxStudio
问个问题
当我在一个buttton上同时绑定了onClick,onClickCapture,发现processDispatchQueue执行了两次,为何现象为:
先收集捕获事件,然后执行捕获回调之后,再收集冒泡事件,再执行冒泡回调?
点赞
1
删除
react版本为17
点赞
回复
八倍镜的头像
删除
高级前端工程师
每次点击事件都需要重新收集每个节点的事件吗
点赞
1
删除
嗯嗯 是的,一些元素节点上绑定的事件是动态的。
1
回复
Tvinsh的头像
删除
前端
好,很好,非常好
1
1
删除
感谢啦
点赞
回复
console_man的头像
删除
Web前端
请问为什么绑定onChange ,就会绑定好几个对应的原生事件呢?这样做的优势是什么呢
1
1
删除
我理解: 这些原生事件是onChange的依赖事件 就触发onChange必定触发 对应的原生事件
1
回复
NeoYu的头像
删除
前端开发工程师 @ 🦐 皮
这节一看就懂 👀[看]
1
1
删除
[玫瑰][玫瑰][玫瑰]
1
回复
咿呀咿呀哦的头像
删除
前端工程师
看多两遍就看懂了
1
2
删除
偶只看了一遍
点赞
回复
删除
那你很厉害
1
回复
shadow不想说话的头像
删除
前端工程师
想请教一下,如果是在button上写的onclick方法,如果按react实现,实际上click方法是最终注册在document元素上的。那我点击button的时候,实际上button对应的dom元素上是没click的监听事件的,那如果触发的click方法? 是通过事件冒泡到document元素上=》通过触发的eventTarget 拿到button元素=〉 判断button元素上是否绑定过react的onclick事件,这样变相执行的么?
点赞
3
删除
请问解决了吗?我也有这个问题
点赞
回复
删除
【首先上面讲到执行 dispatchEvent ,dispatchEvent 执行会传入真实的事件源 button 元素本身。通过元素可以找到 button 对应的 fiber 】你想问的这句话应该能回答了
1
回复
查看更多回复

查看全部 53 条回复