「React 深入」React事件系统与原生事件系统究竟有何区别?

3,494 阅读9分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

大家好,我是小杜杜,我们知道React自身提供了一套虚拟的事件系统,这套系统与原生系统到底有何区别?今天一起来详细看看

作为一个前端,我们会有很多的事件处理,所以学好React事件系统是非常有必要的

关于React事件系统将会分三个方面去讲解,分别是:React事件系统与原生事件系统深入React v16事件系统对比Reactv16~v18事件系统 三个模块,有感兴趣的可以关注下新的专栏:React 深入进阶,一起进阶学习~

在正式开始前,让我们一起来看看以下几个问题:

  1. React为什么要单独整一套虚拟的事件系统,这样做有什么好处?
  2. React的事件系统,与原生的事件系统有什么区别?
  3. 什么是合成事件?
  4. React中是如何模拟冒泡和捕获阶段的?
  5. 所有的事件真的绑定在真实DOM上吗?如果不是,那么绑定在哪里?
  6. React中的阻止冒泡与原生事件系统的阻止冒泡一样吗,为什么?
  7. ...

如果你能耐心的看完,相信一定能帮你更好的了解事件系统,先附上一张知识图,供大家更好的观看,还请各位小伙伴多多支持~

深入React事件系统.png

原生DOM事件

在讲React的事件系统前,我们先复习一下原生DOM事件的概念,来帮助我们更好的理解

注册事件

注册事件:通过给元素添加点击(滚动等)事件,称作注册事件,也叫绑定事件

注册事件共有两种方式,分别是:传统注册方式监听注册方式

传统注册方式

传统注册方式:是以on开头的方式,完成注册方式

如:

// 第一种
    <button onclick="console.log(1)">点击</button>

// 第二种
    <button id="btn">点击</button>

    const btn = document.querySelector('#btn')
    btn.onclick = function () {}

需要注意的是:我们注册的事件都具备唯一性,也就是同一个元素只能设置一个处理的函数,如果有多个,则会进行覆盖

监听注册方式

监听注册方式:是以addEventListener方法来监听元素事件

addEventListener方法并不支持IE 9以下的浏览器,当有需要的时候可以使用attachEvent方法,这个方法支持IE 10以下的浏览器,但此方法建并非标准

如:

   <button id="btn">点击</button>
   
   const btn = document.querySelector('#btn')
   btn.addEventListener('click', () => {})

addEventListener 与传统的方式不同,支持多次绑定事件,但要比传统方式等级低

事件流

DOM事件流共分为三个阶段:事件捕获目标事件冒泡三个阶段

事件捕获:由 DOM 最顶层节点开始,然后逐级向下传播到最具体的元素接收的过程

事件冒泡:事件开始时由最具体的元素接收,然后逐级向上传播到 DOM 最顶层节点的过程

特别注意:

  • JS代码中,只能执行捕获冒泡其中的一个阶段
  • addEventListener 的第三个参数为false代表冒泡(默认),为true代表捕获
  • 在真实的情况下,我们更多的关注是在冒泡上,可以利用冒泡做一些很巧妙的事情,但有时又会带来不必要的麻烦,应该合理的去利用
  • 并不是所有的事件都有冒泡,有些事件并不存在冒泡事件,如:onbluronfocusonmouseenter事件等
  • 合理的利用e.stopPropagation()e.stopImmediatePropagation()来阻止冒泡

扩展:阻止冒泡

这里简单介绍一下e.stopPropagation()e.stopImmediatePropagation()的区别,方便大家更好的理解,

举个栗子🌰:

  <div id="id">
    <button id="btn">点击</button>
  </div> 

  const div = document.querySelector('#id')
  const btn = document.querySelector('#btn')

  document.addEventListener('click', (e) => {
    console.log(1)
  })

  div.addEventListener('click', (e) => {
    console.log(2)
  })

  div.addEventListener('click', (e) => {
    console.log(3)
  })

  div.addEventListener('click', (e) => {
    console.log(4)
  })

  btn.addEventListener('click', () => {
    console.log(5)
  })

当我们点击按钮的时候,执行顺序是:5 > 2 > 3 > 4 > 1,原因是执行了冒泡,会从最底层的btn开始执行,然后是div,最后才是顶层document, 然后根据Js的执行顺序,分别是 2、3 、4

那么我们在console.log(3)上加入e.stopPropagation()看看,结果是什么?

image.png

可以看到执行顺序变成了:5 > 2 > 3 > 4

同样的,我们换成e.stopImmediatePropagation()来看看结果:

image.png 此时结果变成了:5 > 2 > 3

结论:e.stopImmediatePropagation() 相当于e.stopPropagation()的增强版,不但可以阻止向上的冒泡,还能够阻止同级的扩散

扩展:获取事件流的阶段

我们如果想知道触发事件的元素属于三个阶段的哪个阶段时,可以通过e.eventPhase来获取

e.eventPhase为1时,代表捕获阶段,为2时,代表目标阶段,为3时,代表冒泡阶段

事件委托

事件委托:也称事件代理,在JQ中称事件委派,也就是利用事件冒泡,将子级的事件委托给父级加载

也就是说,我们可以通过将监听节点设置在父级上,然后利用冒泡来影响子集,如:

  <ul>
    <li>1</li>
    <li>2</li>
    <li>3</li>
  </ul> 
  
  const ul = document.querySelector('ul')
  const lis = ul.children

  ul.addEventListener('click', (e) => {
    for(let i = 0; i < lis.length; i++) {
      lis[i].style.background = ""
    }
    e.target.style.background = "red"
  })

我们点击对应的节点,点击的节点需要高亮,如果设置在子级上,就需要监听所有子级节点,就非常麻烦,此时我们可以监听父级,来实现效果

img6.gif

初探React事件系统

点击事件究竟去了哪?

我们先来看看原生DOM事件,用传统/监听注册方式下,事件绑定在何处?

企业微信截图_427202df-7c8b-4f76-80fb-c019ce8a222a.png

可以看出,原生DOM事件就绑定在对应的button上,那么React中,也是如此吗?

image.png

我们发现在React中,button 这个元素并没有绑定事件,并且对应的点击事件中有buttondocument两个事件

先来看看button事件,绑定的方法为nonp

image.png

然而绑定的 nonp只是一个空函数,也就说,真正的事件绑定到了document

点击事件究竟存储到了哪?

之前在「React深入」一文吃透虚拟DOM和diff算法中讲过,我们编写的jsx代码首先会被babel转化为React.createElement,最终被转化为fiber对象

接下来我们逐步看看上述的代码转化后的样子:

React.createElement形式:

image.png

fiber对象形式(可以选中当前元素,然后输入console.dir($0)查看当前元素):

企业微信截图_78bd7fff-a3ec-4fde-87ba-2028b6245c81.png

可以发现,事件最终保存在fiber中的memoizedProps 和 pendingProps

什么是合成事件?

合成事件(SyntheticEvent):是React模拟原生 DOM 事件所有能力的一个事件对象,即浏览器原生事件的跨浏览器包装器

简单的来讲,我们在上述的例子中,使用onClick点击事件,而onClick并不是原生事件,而是由原生事件合成的React事件

为了更好的理解,我们来看看inputonChang事件

export default function App(props) {
  return (
    <div>
      <input onChange={(e) => console.log(e)}></input>
    </div>
  );
}

此时在doncument上的事件为

image.png

也就是说,我们绑定的onChange事件最终被处理成了很多事件的监听器,如:blurchangefocusinput

为什么要单独整一套呢?

React为什么要采取事件合成的模式呢?这样做有什么好处呢?

兼容性,跨平台

我们知道有多种浏览器,每个浏览器的内核都不相同,而React通过顶层事件代理机制,保证冒泡的统一性,抹平不同浏览器事件对象之间的差异,将不同平台的事件进行模拟成合成事件,使其能够跨浏览器执行

将所有事件统一管理

在原生事件中,所有的事件都绑定在对应的Dom上,如果页面复杂,绑定的事件会非常多,这样就会造成一些不可控的问题

React将所有的事件都放在了document上,这样就可以对事件进行统一管理,从而避免一些不必要的麻烦

避免垃圾回收

我们来看看React原生事件中的input都绑定onChange事件是什么样子?

image.png

可以看出,原生事件绑定onchange对应的就是change,而React会被处理为很多的监听器

在实际中,我们的事件会被频繁的创建和回收,这样会影响其性能,为了解决这个问题,React引入事件池,通过事件池来获取和释放事件。

也就是说,所有的事件并不会被释放,而是存入到一个数组中,如果这个事件触发,则直接在这个数组中弹出即可,这样就避免了频繁创建和销毁

浅谈合成事件与原生事件

执行顺序对比

我们先模拟下React中的合成事件和原生中的事件顺序,如:

import React, {useEffect, useRef} from "react";

export default function App(props) {

  const ref = useRef(null)
  const ref1 = useRef(null)

  useEffect(() => {
    const div = document.querySelector("div")
    const button = document.querySelector("button")

    div.addEventListener("click", () => console.log("原生:div元素"))
    button.addEventListener("click", () => console.log("原生:button元素"))
    document.addEventListener("click", () => console.log("原生:document元素"))
  }, [])

  return (
    <div onClick={() => console.log('React:div元素')}>
      <button
        onClick={() => console.log('React:按钮元素')}
      >
        执行顺序
      </button>
    </div>
  );
}

执行结果:

image.png

由上图可看出,当DOM(button)元素触发后,会先执行原生事件,再处理React时间,最后真正执行document上挂载的事件

未命名文件.png

与原生事件有何不同?

事件名不同

  • 原生事件,是以纯小写来命名,如:onclick
  • 合成事件,是以小驼峰式来命名,如:onClick

image.png

接受的参数不同

  • 原生事件,接受的参数是字符串,如:Click()
  • 合成事件,接受的参数是函数,如:Click()

事件源不同,阻止默认事件的方式不同

React中,我们的所有事件都可以说是虚拟的,并不是原生的事件

我们在React中拿到的事件源(e) 也并非是真正的事件e,而是经过React 单独处理的e

  • 原生事件中,可以通过e.preventDefault()return false 来阻止默认事件
  • 合成事件中,通过e.preventDefault()阻止默认事件

特别注意,原生事件和合成事件的e.preventDefault()并非是同一个函数,React的事件源e是单独创立的,所以两者的方法也不相同,同时return false也在React中无效

扩展:对比 e.stopPropagation() 和 e.nativeEvent.stopImmediatePropagation

来扩展下阻止冒泡的方法:

为了更好的说明,我们分别使用e.stopPropagation() e.nativeEvent.stopImmediatePropagation有什么效果:

正常触发: 企业微信截图_36ab6a2a-9335-4eb8-be60-4e1cce8134dd.png

e.stopPropagation()触发:

企业微信截图_2b7ef678-cba6-4d8a-ad3f-bff842f44466.png

e.nativeEvent.stopImmediatePropagation触发:

企业微信截图_d85e727d-8953-493b-a9b6-d1e1dbfb2bdd.png

从上图可知:

  • e.stopPropagation():可以阻止当前DOM事件的冒泡,但事实上,e.stopPropagation()只能阻止合成事件的冒泡,即不会阻止顶流document
  • e.nativeEvent.stopImmediatePropagation:于e.stopPropagation()正好相反,只能阻止绑定在document上的监听事件

扩展:冒泡和捕获阶段

React中,所有的绑定事件(如:onClickonChange)都是冒泡阶段执行。

所有的捕获阶段统一加Capture,如onClickCaptureonChangeCapture

举个小例子:

    <button
      onClick={() => {console.log('冒泡')}} 
      onClickCapture={() => {console.log("捕获")}} >
        点击
    </button>

是否可以混用?

我们通过对比原生事件和合成事件后,提出一个疑问,原生事件和合成事件是否可以一起使用?

先来举个栗子🌰,一起看看

import React, {useEffect, useRef} from "react";

export default function App(props) {

  useEffect(() => {
    const button = document.querySelector("button")

    button.addEventListener("click", (e) => {
      e.stopPropagation();
      console.log("原生button阻止冒泡");
    })
    document.addEventListener("click", () => {
      console.log("原生document元素");
    });
  }, [])

  return (
      <button
        onClick={() => {
          console.log('按钮事件')
        }}
      >
        混用
      </button>
  );
}

结果:

image.png

可以发现只执行了原生事件,并没有执行合成事件,这是因为原生事件的执行顺序在合成事件之前,所以导致合成事件没有办法进行触发。

所以两者建议不要进行混用,否则会跳过React的事件机制

End

参考

相关文章

结语

本文通过对比React 事件系统原生事件系统,详细的了解两者的区别,实际上React上的事件都绑定在了document上,就连事件源也并非是原生中的事件源

那么,React究竟如何绑定事件的,又是如何触发事件的?为什么我们必须通过this去绑定对应的事件?又是如何处理批量更新的?... 都是一些我们值得探讨的问题。

感兴趣的可以关注下这个专栏,这个专栏会以进阶为目的,详细讲解React相关的原理、源码、实战,有感兴趣的可以关注下,一起学习,一起进步~