组件(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 phase
、Commit phase
)阶段称为渲染(render)
触发渲染(Render Is Triggered)
触发渲染的两种场景:
- 应用程序初始化(Initial render)
- 更新组件实例中的状态(re-render)
在实践中,React看似只是重新渲染状态(state)更新的组件,但实际上整个应用程序都在渲染流程中
渲染不是立即触发的,而是异步 的(在JS引擎空闲时调度),在事件调度中多个setState会被批处理
Render Phase
回顾一下之前笔记中state更新时的机制
我们存在两个误区:
- 渲染 (render/re-render) 是更新界面/DOM
- React在重新渲染时完全丢弃旧的视图 (DOM)
React是如何在不丢弃旧视图(DOM)的情况下更新新视图(DOM)呢?这就是虚拟DOM 和Fiber的力量了
虚拟DOM
虚拟DOM是由组件树中所有实例创建的所有React元素组成的树
频繁创建和更改DOM树是十分消耗性能的,但构建虚拟DOM树则没有这些顾虑
需要注意的是,重新渲染一个父组件将导致其所有子组件重新渲染,无论props是否更改(如图中D组件的state更新导致E也重新渲染)
这是十分有必要的,因为React不知道子组件是否会收到state更新的影响
在新的虚拟DOM构建完成后,就进入了下一阶段,调和(Reconciliation)+ 对比 (Diffing)
什么是Reconciliation? 我们为什么需要它?
为什么当State改变时我们不去更新整个DOM?
原因有两个:
- 写入DOM的速度相对比较慢
- 通常我们只需要更新 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遵循两种原则:
- 两个不同类型的元素会产生不同的树
- 具有稳定
key props
的元素在渲染过程中保持不变
我们来看一下不同场景中的Diff过程
相同的位置,不同的元素
- React假设整个子树不再有效
- 旧组件被销毁并从DOM中删除,包括状态
- 如果子级保持不变,则可能会重建树(状态重置)
相同的位置,相同的元素
- 将保留元素(以及子元素),包括state
- 如果新的
props
/attributes
在渲染之间发生更改,则会传递这些道具/属性
有时我们并不想保留之前的State
, 这时我们可以使用key
prop
Key Prop
Key
是 diff 算法用来区分元素是否唯一的特殊 prop
Key
能够使 React 区分相同组件类型的不同实例
Key
有两个特性 :
- 当一个
Key
在渲染期间保持不变时,该元素将保留在DOM中(即使树的位置发生了变化) - 当一个
Key
在渲染之间发生变化时,该元素将被销毁,并创建一个新的元素(即使树中的位置与之前相同)。
根据这两个特性, 我们可以有以下使用key
的两种用途
- 在渲染列表时使用
key
(当列表发生变化时能够最大程度的复用)
- 使用
key
重置state
(如果我们在树的相同位置有相同的元素,DOM元素和状态将被保留)
渲染逻辑的规则
在 React 组件中有两种逻辑 : 渲染逻辑(Render Logic)和事件处理函数(Event Handler Functions)
- Render Logic:即函数组件在
return
之前执行的代码,主要参与描述组件视图,组件每次渲染都会执行 - Event Handler Functions:作为处理程序正在侦听的事件的结果执行,常用于更新状态、执行HTTP请求、读取输入字段、导航到另一个页面等等
当涉及到渲染逻辑时,组件必须是纯净的(Pure Component
):给定相同的props(输入),组件实例应该始终返回相同的JSX(输出)。
渲染逻辑必须不产生副作用:不允许与外界交互。因此,在渲染逻辑中:
- 不要执行网络请求(API调用)
- 不要使用计时器
- 不要直接使用 DOM API
- 不要改变函数作用域之外的对象或变量
- 不要更新状态(或
refs
):这会创建一个无限循环
然而程序的运行避免不了副作用,我们在事件处理函数中允许(并且鼓励)副作用!
还有一个特殊的钩子来注册副作用(useEffect
)。
扩展:函数式编程
三大特征
- 拥抱纯函数, 隔离副作用
- 函数是"一等公民"
- 避免对状态的改变(不可变值)
纯函数与副作用
同时满足以下两个特征的函数,我们就认为是纯函数:
- 对于相同的输入,总是会得到相同的输出
- 在执行过程中没有语义上可观察的副作用。
在计算机科学中,函数副作用指当调用函数时,除了返回可能的函数值之外,还对主调用函数产生附加的影响。 ------维基百科
简单地讲:对函数来说,它的正常工作任务就是【计算】,除了计算之外,它不应该搞别的。
如果一个函数除了计算之外,还对它的执行上下文、执行宿主等外部环境造成了一些其它的影响,那么这些影响就是所谓的"副作用"。
这里强推修言老师的 JavaScript 函数式编程实践指南,绝对能让你收获满满!
状态更新的批处理
上文我们提到,Render Phase
不是立即触发的,而是异步 的(在JS引擎空闲时调度),在事件调用中多个setState会被批处理
我们看到上图代码中reset
函数中有三个setState
的函数调用,那这是不是意味着我们会执行三次渲染和提交过程呢? 答案并非如此
React 会对多个状态的更新进行批处理,仅执行一次render
和commit
那么上图中的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就会创建一个组件实例,它就像一个组件的实际物理表现,包含
props
、state
等。组件实例在渲染时将返回一个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
)