一文看懂ReactV18幕后工作原理

组件(Component)、实例(Instance)与元素(Element)

React构建UI流程:组件-->实例-->React元素-->DOM

Component

组件是对UI的描述,是一个返回React元素的函数,通常写成JSX

Component Instance

组件实例是在我们使用组件(图中是<Tab />)时创建的,React会在内部调用组件函数(图中是Tab())。

每个实例都有自己的props、state和生命周期。

jsx 复制代码
  console.log(<Tab num={0} activeTab={activeTab} onClick={setActiveTab} />);

$$typeof: Symbol(react.element)

React实现的安全功能, 保护我们免受跨站点脚本攻击。原理为Symbol不能作为Json字符串传递

React Element

React Element是将组件实例返回的JSX通过调用React.createElement()函数转换后的结果, 它包含创建DOM元素所需的信息

渲染流程(Rendering)是如何工作的

总结上文,从React组件到用户界面的流程大致如下

在React中, 渲染(Rendering)不是更新DOM或在屏幕上显示元素。渲染流程仅发生在React内部,并不会产生视觉上的变化

在下图的四个阶段中,我们通常将中间两个(Render phaseCommit phase)阶段称为渲染(render)

触发渲染(Render Is Triggered)

触发渲染的两种场景:

  1. 应用程序初始化(Initial render)
  2. 更新组件实例中的状态(re-render)

在实践中,React看似只是重新渲染状态(state)更新的组件,但实际上整个应用程序都在渲染流程中

渲染不是立即触发的,而是异步 的(在JS引擎空闲时调度),在事件调度中多个setState会被批处理

Render Phase

回顾一下之前笔记中state更新时的机制

我们存在两个误区:

  1. 渲染 (render/re-render) 是更新界面/DOM
  2. React在重新渲染时完全丢弃旧的视图 (DOM)

React是如何在不丢弃旧视图(DOM)的情况下更新新视图(DOM)呢?这就是虚拟DOMFiber的力量了

虚拟DOM

虚拟DOM是由组件树中所有实例创建的所有React元素组成的树

频繁创建和更改DOM树是十分消耗性能的,但构建虚拟DOM树则没有这些顾虑

需要注意的是,重新渲染一个父组件将导致其所有子组件重新渲染,无论props是否更改(如图中D组件的state更新导致E也重新渲染)

这是十分有必要的,因为React不知道子组件是否会收到state更新的影响

在新的虚拟DOM构建完成后,就进入了下一阶段,调和(Reconciliation)+ 对比 (Diffing)

什么是Reconciliation? 我们为什么需要它?

为什么当State改变时我们不去更新整个DOM?

原因有两个:

  1. 写入DOM的速度相对比较慢
  2. 通常我们只需要更新 DOM 的一小部分,如下图:我们只需将评分Model框展示出来,其余的DOM都没有更改

React的理念是尽可能多地重用现有的DOM, 这个时候就需要去调和 (Reconciliation) 新旧DOM

在Reconciliation的过程中, React会确定实际需要插入、删除或更新哪些 DOM 元素,以反映最新的状态更改

协调器 (The Reconciler):Fiber

在初次渲染过程中,构建虚拟DOM树时,会初始化一个Fiber树,每个组件实例和DOM元素都会有一个,我们称之为"工作单元"。

Fibers只会在初次渲染的过程中构建

有了Fiber,渲染过程可以拆分为工作单元块,任务可以区分优先级,并且可以工作可以暂停、重用或丢弃

  • 支持并发特性例如Suspense
  • 长渲染不会阻塞JS引擎

我们来看上面评分的案例 :

当我们更改App中的State时,Video组件,Btn组件,Modal组件都会被重新渲染,构建新的虚拟DOM, 此时新的虚拟DOM会与当前的Fiber Tree进行调和与对比, 通过对比元素位置更新Fiber Tree, 图中删除了Modal, 更新了Btn

总结

整个Render Phase的流程如下图

Commit Phase and Browser Paint

在收到Render Phase返回的DOM更新列表后, 会进入Commit Phase

这个阶段是同步的, DOM是一次更新,它不能被中断。这是必要的,以便DOM永远不会显示部分结果,确保一致的UI(始终与状态同步)

Commit Phase完成后, Fiber树也会同步更新, 浏览器会将更新后的UI绘制到屏幕上

值得一提的是, 整个Commit Phase是由React DOM去完成的, React不触及DOM, 只工作在Render Phase。它不知道渲染结果会在哪里。Commit Phase可以在不同的平台上执行。

总结

  • Trigger只在初始化和State更新时发生
  • Render Phase不会产生任何可视化输出
  • 重新渲染一个组件也会导致其子组件的重新渲染
  • Render Phase是异步的,可以被拆分, 区分优先级, 暂停, 恢复
  • Commit Phase是同步的, DOM一次更新, 保持UI的一致性

Diff工作原理

在上文我们提到在Render Phase中, 虚拟DOM会与当前的Fiber tree根据元素的位置进行一一比对

那么Diffing过程是怎样工作的呢?

Diffing遵循两种原则:

  1. 两个不同类型的元素会产生不同的树
  2. 具有稳定key props的元素在渲染过程中保持不变

我们来看一下不同场景中的Diff过程

相同的位置,不同的元素

  • React假设整个子树不再有效
  • 旧组件被销毁并从DOM中删除,包括状态
  • 如果子级保持不变,则可能会重建树(状态重置)

相同的位置,相同的元素

  • 将保留元素(以及子元素),包括state
  • 如果新的props / attributes在渲染之间发生更改,则会传递这些道具/属性

有时我们并不想保留之前的State, 这时我们可以使用key prop

Key Prop

Key 是 diff 算法用来区分元素是否唯一的特殊 prop

Key 能够使 React 区分相同组件类型的不同实例

Key有两个特性 :

  1. 当一个Key在渲染期间保持不变时,该元素将保留在DOM中(即使树的位置发生了变化)
  2. 当一个Key在渲染之间发生变化时,该元素将被销毁,并创建一个新的元素(即使树中的位置与之前相同)。

根据这两个特性, 我们可以有以下使用key的两种用途

  1. 在渲染列表时使用key (当列表发生变化时能够最大程度的复用)
  1. 使用key重置state(如果我们在树的相同位置有相同的元素,DOM元素和状态将被保留)

渲染逻辑的规则

在 React 组件中有两种逻辑 : 渲染逻辑(Render Logic)和事件处理函数(Event Handler Functions)

  1. Render Logic:即函数组件在return之前执行的代码,主要参与描述组件视图,组件每次渲染都会执行
  2. Event Handler Functions:作为处理程序正在侦听的事件的结果执行,常用于更新状态、执行HTTP请求、读取输入字段、导航到另一个页面等等

当涉及到渲染逻辑时,组件必须是纯净的(Pure Component):给定相同的props(输入),组件实例应该始终返回相同的JSX(输出)

渲染逻辑必须不产生副作用:不允许与外界交互。因此,在渲染逻辑中:

  • 不要执行网络请求(API调用)
  • 不要使用计时器
  • 不要直接使用 DOM API
  • 不要改变函数作用域之外的对象或变量
  • 不要更新状态(或refs):这会创建一个无限循环

然而程序的运行避免不了副作用,我们在事件处理函数中允许(并且鼓励)副作用!

还有一个特殊的钩子来注册副作用(useEffect)。

扩展:函数式编程

三大特征

  • 拥抱纯函数, 隔离副作用
  • 函数是"一等公民"
  • 避免对状态的改变(不可变值)

纯函数与副作用

同时满足以下两个特征的函数,我们就认为是纯函数:

  • 对于相同的输入,总是会得到相同的输出
  • 在执行过程中没有语义上可观察的副作用。

在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。 ------维基百科

简单地讲:对函数来说,它的正常工作任务就是【计算】,除了计算之外,它不应该搞别的。

如果一个函数除了计算之外,还对它的执行上下文、执行宿主等外部环境造成了一些其它的影响,那么这些影响就是所谓的"副作用"。

这里强推修言老师的 JavaScript 函数式编程实践指南,绝对能让你收获满满!

状态更新的批处理

上文我们提到,Render Phase不是立即触发的,而是异步 的(在JS引擎空闲时调度),在事件调用中多个setState会被批处理

我们看到上图代码中reset函数中有三个setState的函数调用,那这是不是意味着我们会执行三次渲染和提交过程呢? 答案并非如此

React 会对多个状态的更新进行批处理,仅执行一次rendercommit

那么上图中的console.log(answer),这个answer的值会是空字符串么? 答案也是否定的

更新后的状态变量不会在setState调用后立即可用,而只能在re-render之后才可用,这同时也适用于只更新一个状态变量的情况

比如我们在reset中执行

js 复制代码
setAnswer(+answer + 1)
setAnswer(+answer + 1)

我们会得到1而不是2,因为此时我们answer还未真正变更。

如果我们需要根据以前的state来更新状态,我们需要使用setState的回调形式:(setAnswer(answer=>...))

综上,我们可以得出结论:状态的更新是异步的

ReactV17 vs ReactV18

上文我们看到React在事件处理函数中对setState的批处理,那么在其他场景中,是否还是这样的呢?

这一点React18与之前版本有着很大区别

React17及之前版本是不支持在定时器、Promise和原生事件中对setState进行批处理,React18则在任何场景均可实现批处理

我们可以通过在ReactDOM.flushSync()中包装一个状态更新来退出自动批处理。

React中的事件是如何工作呢?

事件冒泡与事件委托

默认情况下,事件处理程序在冒泡阶段监听目标上的事件

我们可以使用e.stopPropagation()来阻止冒泡

事件委托是指在一个父元素中集中处理多个子元素的事件(使用e.target进行判断),这样做对于性能和内存更好,因为它只需要一个处理程序函数

React对事件的处理

React在幕后为我们应用程序中的所有事件执行事件委托(在root DOM容器上注册所有事件处理程序。)

合成事件

React会对封装DOM原生事件,封装后的合成事件具有与本机事件对象相同的接口,如stopPropagation()preventDefault(),同时修复了浏览器的不一致性,以便事件在所有浏览器中以完全相同的方式工作

除了滚动之外,大多数合成事件都会冒泡(focus, blur, change)

合成事件 VS 原生事件

  • 事件处理程序的属性使用camelCase命名(onClick而不是onClick或click)

  • 默认行为不能通过返回false来阻止(只能通过使用preventDefault())

  • 如果你需要在捕获阶段处理事件,可以添加"Capture"(例如:onClickCapture)

总结

  • 组件就像最终出现在屏幕上的UI的蓝图。当我们"使用"一个组件,React就会创建一个组件实例,它就像一个组件的实际物理表现,包含propsstate等。组件实例在渲染时将返回一个React元素
  • 渲染仅意味着调用组件函数并计算需要插入、删除或更新哪些DOM元素。它与向DOM写入无关。因此,每次渲染和重新渲染组件实例时,都会再次调用该函数
  • 只有应用初始渲染和状态更新才会导致渲染,它会发生在整个应用程序中,而不仅仅是一个组件
  • 当一个组件实例被重新渲染时,它的所有子实例也将被重新渲染。这并不意味着所有的子元素都会在DOM中得到更新,这要感谢协调,它检查哪些元素在两次渲染之间实际发生了变化
  • Diffing决定了React需要添加或修改哪些DOM元素。如果在渲染之间,某个React元素停留在元素树中的相同位置,相应的DOM元素和组件状态将保持不变。如果元素被改变到不同的位置,或者它是不同的元素类型,DOM元素和状态将被销毁
  • 给元素一个key prop可以让React区分多个组件实例。当一个键在渲染过程中保持不变时,该元素将保留在DOM中。这就是为什么我们需要在列表中使用key。当我们在渲染之间改变键时,DOM元素将被销毁并重建。我们用这个作为重置状态的技巧
  • 永远不要在另一个组件中声明一个新组件!这样做会在每次父组件重新渲染时重新创建嵌套组件。React总是将嵌套组件视为新的,因此每次父状态更新时都会重置其状态
  • 为组件实例生成JSX输出的逻辑(Render Logic)不允许产生任何副作用:没有API调用、没有计时器、没有对象或变量突变、没有状态更新。副作用在事件处理程序和useEffect中是允许的
  • DOM在提交阶段更新,但不是由React更新,而是由一个名为ReactDOM的"渲染器"更新。这就是为什么我们总是需要在React web应用项目中包含这两个库的原因。我们可以使用其他渲染器在不同的平台上使用React,例如构建移动或本地应用程序
  • 事件处理程序函数中的多个状态更新是批处理的,因此它们同时发生,只导致一次re-render。这意味着我们不能在更新状态变量后立即访问它:状态更新是异步的。从React18开始,批处理也会发生在定时器、Promise和原生事件处理中
  • 当在事件处理程序中使用事件时,我们访问的是一个合成事件对象,而不是浏览器的原生对象 ,因此事件在所有浏览器中都以相同的方式工作。不同之处在于,除了滚动之外,大多数合成事件都会冒泡(focus, blur, change)
相关推荐
天下无贼!36 分钟前
2024年最新版Vue3学习笔记
前端·vue.js·笔记·学习·vue
Jiaberrr36 分钟前
JS实现树形结构数据中特定节点及其子节点显示属性设置的技巧(可用于树形节点过滤筛选)
前端·javascript·tree·树形·过滤筛选
赵啸林39 分钟前
npm发布插件超级简单版
前端·npm·node.js
罔闻_spider1 小时前
爬虫----webpack
前端·爬虫·webpack
吱吱鼠叔1 小时前
MATLAB数据文件读写:1.格式化读写文件
前端·数据库·matlab
爱喝水的小鼠2 小时前
Vue3(一) Vite创建Vue3工程,选项式API与组合式API;setup的使用;Vue中的响应式ref,reactive
前端·javascript·vue.js
WeiShuai2 小时前
vue-cli3使用DllPlugin优化webpack打包性能
前端·javascript
Wandra2 小时前
很全但是超级易懂的border-radius讲解,让你快速回忆和上手
前端
ice___Cpu2 小时前
Linux 基本使用和 web 程序部署 ( 8000 字 Linux 入门 )
linux·运维·前端
JYbill2 小时前
nestjs使用ESM模块化
前端