你会React?那说说「React事件系统」

256 阅读18分钟

序言

React为了抹平不同浏览器之间事件系统的差异,实现了一套自己的事件系统。本文将深入浅出讨论React事件系统,将详细解释事件委托、合成事件、事件池和批量更新的概念,以及结合面试常考点为大家梳理React事件系统

一张脑图全文重点概括。(PS: 最近发现脑图学习能够很好地帮助自己验证对知识的掌握理解,建议大家在学完东西的时候可以自己绘制一个脑图来加强认识。)

image.png

1. 特点

  • 进行浏览器兼容,实现更好的跨平台 React采用的是顶层事件代理机制,能够保证冒泡一致性,可以跨浏览器执行。React提供的合成事件用来抹平不同浏览器事件对象之间的差异,(具体差异可以参见常见问题部分)将不同平台事件模拟合成事件.

  • 避免垃圾回收(频繁创建和删除事件对象) 在大规模DOM操作、频繁事件触发的场景,事件对象可能会被频繁创建和回收,引发垃圾回收,React引入事件池,在事件池中获取或释放事件对象,避免频繁地去创建和销毁,提高性能。

  • 方便事件统一管理和事务机制

2. React事件系统的关键点

2.1 合成事件(Synthetic Events)

  1. 事件委托:React 不会直接在 DOM 元素上添加事件监听器,而是在文档根级别(17前是 document,17后是root节点)添加一个事件监听器。当事件发生时,React 会使用事件的目标(target)和当前目标(currentTarget)来确定应该如何处理事件。这种方法称为事件委托,它可以减少内存占用并提高性能,因为不需要为每个元素添加和删除事件监听器。

  2. 合成事件映射多个原生事件: 简化开发者对事件的处理,React 的合成事件可以对应多个原生事件,例如,React 的 onChange 事件对应原生的 inputtextareaselectchange 事件,以及 inputtextareainput 事件。

  3. 合成事件对象: 创建一个合成事件对象(e)。模拟原生浏览器事件的接口,包括 stopPropagationpreventDefault 方法,这两个方法在所有浏览器中都可用。

2.2 模拟事件传播机制

大概过程如下:

  1. 通过事件委托在document上绑定事件,在事件触发时(React安装事件监听器是在事件冒泡到document上的时候),根据e.target寻找触发事件的DOM节点,找到其对应的FiberNode(即虚拟DOM节点)

  2. 收集从当前FiberNode到根FiberNode之间所有注册的该事件对应回调

  3. 模拟冒泡捕获阶段

  • ​React16中:

    • ​1. 捕获事件会被unshift插入在回调队列前面 
    • ​2. 冒泡事件会被Push在回调队列后面
    • ​3. 依次执行队列中的事件,按顺序来模拟捕获=>冒泡
  • ​React17中

    • ​通过addEventListener第三个参数来控制事件是在冒泡/捕获阶段执行(不传默认是false,是冒泡)

2.3 批量更新和异步执行

React 还提供了一些优化,例如批量更新和异步执行,这可以提高性能。例如,如果你在一个事件处理器中多次调用 setState,React 会将这些更新批量处理,从而减少重新渲染的次数。

[常见问题中有配置的题目和验证Demo](### 3.3 阻止原生事件,合成事件会执行吗?组织合成事件,原生事件会执行吗)

2.4 事件委托机制原理?

2.4.1 含义与原理

事件机制

在浏览器中,事件机制通常是先捕=>执行=>然后冒泡。这个过程可以通过下图进行理解:

![[Pasted image 20230709103111.png]]

这个图展示了事件从 document 开始,经过各级元素,到达目标元素(target),然后再从目标元素冒泡回 document 的过程。

事件委托

也称事件代理,是利用事件冒泡的原理,将事件监听器添加到一个父元素上,这个监听器会处理由子元素触发并冒泡到父元素的所有事件。

事件委托工作原理:

基于两个主要的 DOM 事件特性:事件冒泡和事件目标。

  1. 事件冒泡:当一个事件(如点击或键盘按键)在一个元素(如一个按钮)上触发时,这个事件不仅仅会在这个元素上触发,还会在这个元素的所有祖先元素上触发,一直冒泡到 document 对象。这就是所谓的事件冒泡。

  2. 事件目标:当一个事件冒泡时,你可以通过事件对象的 target 属性来获取到最初触发事件的元素。这就是事件目标。

事件委托就是利用这两个特性,只在一个父元素上设置一个事件监听器,来处理在其所有子元素上触发的事件。当事件触发并冒泡到父元素时,我们可以检查事件的目标,看看事件是在哪个子元素上触发的,然后相应地处理事件。

2.4.2 事件委托的优点:

  1. 内存效率:由于只需要在父元素上添加一个事件监听器,而不是在每个子元素上都添加,所以可以大大减少内存占用,提高性能。

  2. 处理动态元素:如果在运行时动态添加了新的子元素,那么无需为新的子元素添加新的事件监听器,因为事件监听器已经在父元素上,新的子元素触发的事件会自动冒泡到父元素上。

需要注意的是,不是所有的事件都会冒泡,例如:focus、blur、mouseenter、mouseleave等,这些事件不会冒泡,因此在这些事件上不能使用事件委托。

2.4.3 事件委托代码示例

// 获取父元素
let parent = document.getElementById('parent');

// 在父元素上添加事件监听器
parent.addEventListener('click', function(e) {
  // 检查事件的目标元素是否是我们想要的元素
  if (e.target && e.target.nodeName == 'li') {
    // 处理事件
    console.log('List item ', e.target.id.replace('post-', ''), ' was clicked!');
  }
});

2.5 原生事件 ? 合成事件?

  • 原生事件: 在 componentDidMount生命周期里边进行addEventListener绑定的事件

  • 合成事件: 通过 JSX 方式绑定的事件,比如 onClick={() => this.handle()}

  • 合成事件的触发是基于浏览器的事件机制来实现的,通过冒泡机制冒泡到最顶层元素,然后再由 dispatchEvent 统一去处理。在react中,我们绑定的事件onClick等,并不是原生事件,而是由原生事件合成的React事件,比如 click事件合成为onClick事件。比如blur , change , input , keydown , keyup等 , 合成为onChange。

  • 原生事件与合成事件写法最好不要混用,在于如果你阻止了原生事件的冒泡,可能会影响到其他写合成事件的地方

2.6 React事件批量更新、异步执行原理

批量更新

批量更新是指在一次事件处理中,如果有多次状态的更新,React不会立即更新视图,而是将这些更新操作放入一个队列中,然后在事件处理结束后,一次性执行这个队列中的所有更新操作,然后再更新视图。这样,无论状态更新多少次,DOM的操作只会进行一次。

异步执行

异步执行是指React在更新视图时,不是立即执行,而是将这个操作放入一个任务队列中,然后在浏览器的空闲时间,再去执行这个任务。这样可以避免在忙碌的时候进行视图更新,从而阻塞浏览器的其他任务。

实现原理

React的批量更新和异步执行的实现,主要依赖于React的事务机制和调度机制。

事务机制(Transaction)

React 的事务机制是一种用于封装和控制代码执行过程的模式,它的主要目的是确保在执行一系列操作时,无论过程中是否出现错误,都能保证在操作开始前后,系统的状态是一致的。

事务机制的关键在于它的两个阶段:初始化阶段和关闭阶段。在初始化阶段,你可以设置系统到一个特定的状态,或者创建一些需要在事务过程中使用的资源。然后,你可以执行你的主要操作。无论这个操作是否成功,事务都会在操作结束后调用所有包装器的关闭方法。

关闭方法的作用是清理初始化方法创建的资源,或者将系统恢复到事务开始前的状态。这就是事务如何保证系统状态一致性的:无论主要操作的结果如何,事务都会尽力将系统恢复到一个稳定的状态。

以下是事务的主要步骤:

  1. 初始化:在事务开始时,会调用所有包装器的初始化方法。这些方法可以用来设置系统到一个特定的状态,或者创建一些需要在事务过程中使用的资源。

  2. 执行:在所有初始化方法执行完毕后,事务会执行主要的操作。这个操作通常是由用户提供的,例如更新 DOM、改变组件的状态等。

  3. 关闭:无论主要的操作是否成功,事务都会在操作结束后调用所有包装器的关闭方法。这些方法可以用来清理初始化方法创建的资源,或者将系统恢复到事务开始前的状态。

调度机制

是指React在执行更新操作时,会将这个操作放入一个任务队列中,然后在浏览器的空闲时间,再去执行这个任务。这个机制的实现,主要依赖于浏览器的requestIdleCallback方法。

2.3.1 setState是同步还是异步?

(Ps: setState是class组件中的setState)

  1. 异步setState:React中的setState方法是异步的,在调用setState后,React并不会立即更新组件,而是将这个对象放到更新队列中,稍后进行批量更新。
  2. 同步执行的情况:虽然setState通常是异步的,但在某些情况下,React会同步执行更新。例如,在React的生命周期方法或React事件处理函数中,setState是异步的,但如果在setTimeout或者原生事件中调用setState,那么它是同步的。
解释同步的情况

React有自己的一套事件处理系统和调度机制。当你在React组件的生命周期方法或者React的事件处理器(例如onClick或者onChange)中调用setState,这些调用会被React的事件系统捕获,并且React会根据需要进行批处理和调度,这通常会导致异步的状态更新。(

然而,当你在setTimeout或者原生事件(例如window.addEventListener)的回调函数中调用setState,这些调用就不再被React的事件系统所控制,而是直接在JavaScript的运行环境中执行。在这种情况下,React无法对这些setState调用进行批处理和调度,因此它们会立即执行,也就是同步的。

这是因为setTimeout和原生事件处理器都是在浏览器的事件循环中运行的,而不是React的事件系统。当你在这些函数中调用setState,你实际上是在告诉浏览器“在下一个事件循环中运行这个函数”。因此,这个函数的执行已经跳出了React的控制范围,进入了浏览器的事件循环,也就是JavaScript的运行环境。

(不推荐在类组件的生命周期之外直接调用setState,因为这可能会导致意外的行为或错误。)

2.3.2 hook中的useState是同步还是异步?

React 的 useState hook 不是异步的,但它的行为可能会让你误以为它是异步的。这是因为 React 有一个优化机制,称为批量更新(batching),它可以将多个状态更新合并到一个渲染周期中。

在函数组件中,当你调用 useState 的更新函数(例如 setCount)时,React 会将新的状态值放入一个队列中,而不是立即更新状态。然后,React 会在稍后的时间(通常是下一个事件循环)进行一次渲染,这时所有的状态更新都会被应用。这就是为什么 useState 的更新函数可能会让人误以为它是异步的。

在 React 16 和 17 中,React 只在 React 的事件处理函数中进行批量更新。这意味着如果你在一个非 React 的事件处理函数(例如一个 setTimeoutPromise.then 的回调函数)中调用 useState 的更新函数,React 不会进行批量更新,而是立即更新状态并重新渲染组件。

在 React 18 中,React 引入了一个新的特性,叫做并发模式(Concurrent Mode)。在并发模式下,React 可以在任何时候进行批量更新,不仅仅是在 React 的事件处理函数中。这意味着即使你在一个非 React 的事件处理函数中调用 useState 的更新函数,React 也会进行批量更新。

3. 常见问题

3.1 React中如何绑定捕获事件,冒泡事件?

从React 17版本开始,React引入了新的事件系统,并且增加了对捕获阶段事件监听的支持。你可以通过在事件名称后面添加Capture来绑定捕获阶段的事件监听。

例如,如果你想在捕获阶段监听一个点击事件,你可以这样做:

class ExampleComponent extends React.Component {
  handleClickCapture(event) {
    console.log('捕获阶段');
  }

  handleClick(event) {
    console.log('冒泡阶段');
  }

  render() {
    return (
      <div 
        onClickCapture={this.handleClickCapture.bind(this)} 
        onClick={this.handleClick.bind(this)}
      >
        点击我
      </div>
    );
  }
}

上述代码中的onClickCapture就是在捕获阶段监听点击事件,onClick则是在冒泡阶段监听点击事件。点击这个元素,会首先触发捕获阶段的事件监听(handleClickCapture方法),然后触发冒泡阶段的事件监听(handleClick方法)。

3.2 如何阻止事件默认行为和冒泡?

阻止事件的默认行为和冒泡则需要使用合成事件对象的 preventDefaultstopPropagation 方法。注意,简单地在事件处理函数中返回 false 是无法阻止默认行为和冒泡的。

3.3 阻止原生事件,合成事件会执行吗?组织合成事件,原生事件会执行吗

React 的合成事件系统和浏览器的原生事件是两个独立的系统,它们的执行顺序在 React 17 版本之前和之后是有所变化的。

React 16 :

原生事件捕获 -> 原生事件冒泡 (冒泡到外层的时候绑定合成事件的执行器)-> 合成事件捕获 -> 合成事件冒泡

整体结论:

  1. 原生、合成事件捕获、冒泡阶段都阻止冒泡: 只有原生事件的捕获执行
  2. 原生、合成事件冒泡阶段都阻止冒泡:原生事件的捕获冒泡都执行、合成事件不执行
demo验证

React16事件在线demo

image.png

import * as React from "react";
import "./styles.css";

export class ExampleComponent extends React.Component {
    componentDidMount() {
        // 添加原生事件监听器
        // 通过绑定时间时第三个参数传true在捕获阶段添加事件
        document.getElementById("btn").addEventListener(
            "click",
            (e) => {
                console.log("原生事件--捕获阶段");
                e.stopPropagation();
                console.log("阻止原生事件冒泡");
            },
            true
        );

        document.getElementById("btn").addEventListener("click", (e) => {
            console.log("原生事件--冒泡阶段");
            e.stopPropagation();
        });

        document.getElementById("btn2").addEventListener(
            "click",
            (e) => {
                console.log("原生事件--捕获阶段");
            },
            true
        );

        document.getElementById("btn2").addEventListener("click", (e) => {
            console.log("原生事件--冒泡阶段");
            e.stopPropagation();
        });
    }

    handleClickCaputure = (e) => {
        console.log("合成事件捕获阶段执行");
        e.stopPropagation();
    };

    handleClickCaputureNotStop = (e) => {
        console.log("合成事件捕获阶段执行");
    };

    handleClick = (e) => {
        console.log("合成事件冒泡阶段执行");
        e.stopPropagation();
    };

    render() {
        return (
            <>
                <button
                    id="btn"
                    onClick={this.handleClick}
                    onClickCapture={this.handleClickCaputure}
                >
                    原生、合成事件捕获、冒泡阶段都阻止冒泡: 只有原生事件的捕获执行
                </button>

                <button
                    id="btn2"
                    onClick={this.handleClick}
                    onClickCapture={this.handleClickCaputureNotStop}
                >
                    原生、合成事件冒泡阶段都阻止冒泡:原生事件的捕获冒泡都执行、合成事件不执行
                </button>
            </>
        );
    }
}

export default ExampleComponent;

React17:

合成事件捕获 -> 原生事件捕获 -> 原生事件冒泡 -> 合成事件冒泡

demo的验证和执行阶段是吻合的,整体结论是:

  1. 原生、合成事件捕获、冒泡阶段都阻止冒泡: 只有合成事件的捕获阶段执行
  2. 原生、合成事件冒泡阶段都阻止冒泡:原生冒泡阶段执行,合成事件冒泡不执行
DEMO验证

在线Demo验证

image.png

import * as React from "react";
import "./styles.css";

export class ExampleComponent extends React.Component {
    componentDidMount() {
        // 添加原生事件监听器
        // 通过绑定时间时第三个参数传true在捕获阶段添加事件
        document.getElementById("btn").addEventListener(
            "click",
            (e) => {
                console.log("原生事件--捕获阶段");
                e.stopPropagation();
                console.log("阻止原生事件冒泡");
            },
            true
        );

        document.getElementById("btn").addEventListener("click", (e) => {
            console.log("原生事件--冒泡阶段");
            e.stopPropagation();
        });

        document.getElementById("btn2").addEventListener(
            "click",
            (e) => {
                console.log("原生事件--捕获阶段");
            },
            true
        );

        document.getElementById("btn2").addEventListener("click", (e) => {
            console.log("原生事件--冒泡阶段");
            e.stopPropagation();
        });

    }

    handleClickCaputure = (e) => {
        console.log("合成事件捕获阶段执行");
        e.stopPropagation();
    };

    handleClickCaputureNotStop = (e) => {
        console.log("合成事件捕获阶段执行");
    };

    handleClick = (e) => {
        console.log("合成事件冒泡阶段执行");
        e.stopPropagation();
    };

    render() {
        return (
            <>
                <button
                    id="btn"
                    onClick={this.handleClick}
                    onClickCapture={this.handleClickCaputure}
                >
                    原生、合成事件捕获、冒泡阶段都阻止冒泡: 只有合成事件的捕获阶段执行
                </button>
                <button
                    id="btn2"
                    onClick={this.handleClick}
                    onClickCapture={this.handleClickCaputureNotStop}
                >
                    原生、合成事件冒泡阶段都阻止冒泡:原生冒泡阶段执行,合成事件冒泡不执行
                </button>
            </>
        );
    }
}


export default ExampleComponent;

React17事件demo

3.4 React 17 VS React 16 不同点

  1. 事件绑定: 17事件绑定在id为root的外层容器上,而不是document上。这样好处是有利于微前端的,微前端一个前端系统中可能有多个应用,如果继续采取全部绑定在document上,那么可能多应用下会出现问题。
  2. 模拟冒泡和捕获: React17是通过分别绑定原生的冒泡、捕获事件来模拟。React16是通过队列的顺序来模拟,16的冒泡捕获都是在原生事件冒泡之后触发的。React17的处理使得React事件系统和原生事件更具一致性。
  3. React17去事件池: 在现代浏览器中事件池性能优化不大,但是和原生事件的表现不够一致。(在事件之外访问e会出现问题,因为e会被复用和重置) React 17 removes the “event pooling” optimization from React. It doesn’t improve performance in modern browsers and confuses even experienced React users:

3.5 React中的事件为什么需要手动去绑定this指向?

JSX语法实际上是createElement的语法糖,在createElement的时候讲方法进行赋值,丢失了上下文,在调用的时候又因开启严格模式,this指向指向了undefiened

3.6 react如何处理事件的注册和绑定过程

React 使用合成事件(Synthetic Event)来对事件进行处理。事件注册通常在 JSX 中进行,例如 <button onClick={this.handleClick}>Click me</button>

但实际上,React 在内部为每种事件类型维护一个事件池,并未真正在 DOM 上注册事件。事件绑定则发生在根节点(React 17 之后为容器节点),不是具体的 DOM 元素上。React 采用事件代理的方式,只在顶层注册一个事件处理函数,然后在内部进行事件的管理和调度。具体流程如下:

  • 在执行 diff 算法时,若发现存在 React 合成事件,例如 onClick,会按照事件系统逻辑单独处理。
  • 根据合成事件类型,找到对应的原生事件类型,大部分事件按照冒泡逻辑处理,少数事件(如 scroll)按照捕获逻辑处理。
  • 调用内部方法 addTrappedEventListener 进行真正的事件绑定。事件绑定在 document 上,dispatchEvent 为统一的事件处理函数。

3.7 React合成事件抹平了在浏览器间的差异,这些差异点?

  • 事件模型:大部分现代浏览器使用的是W3C的事件模型,即先捕获后冒泡。但是IE8及更早版本的IE使用的是自己的事件模型,即只有冒泡阶段。

  • 事件名称:例如,某些浏览器可能使用"transitionend"来表示CSS过渡结束的事件,而其他浏览器可能使用"webkitTransitionEnd"。

  • 事件对象的属性和方法:例如,所有的浏览器都提供了一个名为"target"的属性来表示触发事件的元素,但是IE8及更早版本的IE使用的是"srcElement"。另一个例子是在阻止默认行为时,大部分浏览器使用的方法是"preventDefault",但是IE8及更早版本的IE使用的是"returnValue"。

参考文章

卡颂60行代码实现React的事件系统 一文读懂React合成事件 【React】786- 探索 React 合成事件 一文吃透react事件系统原理 由浅到深的React合成事件 React17事件机制