带着原理重读 React 官方文档(一)
# 前言
带着原理重读 React 官方文档
系列文章并不是一个关注原理细节的文章系列,而是作为一个描述想法、思想和设计哲学的文章系列。在这里不会有太多的代码,但是会有大量的文字。
本质上这个系列是对 React 官方文档的重温,从原理的角度去体会文档中的一些有价值的、可能会被大家所忽略的内容而细细嚼之。至此,文章难免有部分冗长无味、肆意扩展的部分。
其实,我并非怀着正经技术文章的心态去写这个系列,而是想到何处便写到何处,写到尽兴为止。无论您是细读之,还是作为茶余饭后的消遣,我认为都无大碍。瑾以记之。
# React 不是一个 MVC 框架
原文
React 不是一个 MVC 框架。
React 是一个用于构建可组合用户界面的库。 它鼓励创建那些用于呈现随时间变化数据的、可复用的 UI 组件。
React is a library for building composable user interfaces. It encourages the creation of reusable UI components which present data that changes over time.
# React 并不是一个 MVC 框架
React 不存在所谓的 Controller 的概念,并非由 MVC 的架构思想所驱动。React 的目的是构建可组合用户界面,于视图库而言,MVC 并不适合,因为 Controller 并不具备响应式的特征。
# React 也不是一个 MVVM 框架
MVVM 与 MVC 最明显的区别是 VM 的概念,VM 即 ViewModel,是 View 和 Model 沟通的桥梁,既然是桥梁,必然具有重要的连接作用,也即是双向绑定。双向绑定是 MVVM 最大的特征,它体现一种双向数据流的思想,即数据的变化通过 ViewModel 的连接作用影响到视图,视图的变化反过来又会通过 ViewModel 的连接作用影响到数据。这种双向绑定的过程,即是所谓的响应性。
双向绑定的特征要求 ViewModel 应该是可观测的、可响应的,即具有响应性。实现响应性的方法有很多,最简单的方式就是通过代理,可通过代理的方式观测和控制 ViewModel 内部发生的变化,对数据的流出和流入做一定的拦截和控制。这个道理也很简单,如果把数据看作是流水的话,若需要对水量进行观测和控制的话,我们只需要使用一个蓄水池并且使水流的流入和流水使用单独的通道,然后对水流的流入和流出进行流量和流速的观测即可。比如 Vue2 的响应式是通过 Object. defineProperty
实现的,vue3 的响应式是通过 Proxy API
实现的。所使用的 API 不同,无非是 API 实现的进步,但是万变不离其宗,其本质还是 “代理(Proxy)” 的思想。其实,如果细究起来,Vue 对于 MVVM 的应用也只是广义上的应用,因为 Model 在狭义上指的是 ORM 的模型,此为后端的概念,显然对于视图框架而言,“Model” 便延伸到广义的 “数据” 或者 “数据来源” 的意思。数据的来源有很多,如来源于 XHR 的请求、来源于 LocalStorage/indexedDB/Session/Cookie 等、来源于 Vuex 等状态管理工具等等,数据的作用也可能各有不同,有用作渲染的纯数据、控制视图变化的数据、需要被 ViewModel 分发的其他数据等等,这些都被抽象到 Model 中,因 ViewModel 的代理而具有响应性。
但是,React 并非 MVVM 架构的框架。因为 React 中的响应式并没有双向绑定的特征。
# React 创建呈现随时间变化数据的组件
相反的,在 React 的响应式中,使用的是基于属性和状态的单向数据流的模型。当然这样说有点具象,这种单向数据流的响应式还需结合原理来看才显得透彻。但是我们可以从一个更为抽象和概括的视角去体会 React 响应式的特征:
ui = render (data)
如果我们从视频的帧的角度去理解这样的渲染公式的话就能体会其中的深意。React 中写道: React 用于渲染 呈现随时间变化数据的
组件,这里所谓 随时间变化数据
有两层含义:一是时间的连续性,二是数据的呈现离散性质的变化。我们如果从数学上自变量和因变量的视角来看这个问题的话,大概可以总结出这样的规律: view=f(t,data)
,即视图是在因变量时间和数据作用下的结果。所谓的视图渲染,不过是将视图的变化呈现在屏幕(视图媒介)上而已,那么如何解决时间的连续性问题呢?看下视频渲染的思路我们大概就能清晰明了了,视频显然有着类似的规律 video=f(t,yuvData)
,( yuvData
只是示例,也可以是其他格式的图像数据)同样的时间的连续性,视频组件采用了帧的概念,为什么采用帧的概念呢?这是因为对于连续的时间做出反应具有不可估量的高成本,然而任何视图媒介在人眼的物理缺陷下都有一定的极限,即人眼所能认知的变化是有限度的,屏幕是采用了这个原理,浏览器的绘制同样是采用了这样的原理,那么同等地,React 中也是借用了这样的原理。
数据在通过 render
这样的一个渲染器的渲染下做出了响应性的反应,这种针对数据变化的响应是有一定的频率的,视图正在在这样的频率下借用浏览器的渲染机制展示在人眼前。大家可能会疑问这样一个帧率的机制是否会很复杂,其实不然,因为帧率本身就是浏览器的内部机制,而频率从本质上也就是一个定时器而已,React 要做的,无非就是在正确的时机去做正确的任务,如果你已经阅读过调度器的篇章,您可能会对此颇有感慨。
我们可以明了的是, render
机制的核心便是调度器。那么如果撇开调度器不谈,从 data
到 ui
的这样一个单向的数据流是如何被实现的呢?
# 基于属性和状态的单向数据流的模型
我们知道,在 React 中,组件的视图是通过 JSX
所定义和描述的(注意,准确来讲是组件的视图,并非是组件),组件则是通过函数和闭包原理来定义和描述的。然而这样的描述本身是静态的,并不能满足组件的可交互性的需求,因此,属性和状态便由此产生。属性描述组件之间的动态的数据流,而状态则描述组件内部的动态的数据流。
React 中数据流是单向的,不可逆的!无论是属性还是状态,都必须遵循这个原则。属性是不可变的(从组件内部而言不可写),必须以组件树的顺序进行流动(即从父组件流向子组件,或者说从组件树的根部流向枝叶),状态是可变的(从组件内部而言可读且可写,Context 本质上是更高层组件的状态),但状态的变化必须从 setState
被收集(注意,以下皆以函数式组件为例,类组件可自行类比)由 React 中渲染层进行渲染(引用最新的状态渲染),以此循环往复。React 这样的响应性设计实际上有了更多的控制反转(IoC)的意味,这使得 React 对于整个渲染过程有了更多的控制权和灵活性以实现较为复杂的设计理念(如 fiber 与调和)和耳目一新的新特性(如 lazyComponent,suspense 等)。当然,这也使得 React 的源码更为复杂,代码量更为庞大。
如果从这样的观点再来回看函数式组件的写法,其实更深刻的体现了 “定义” 组件的意味。我们可以慢慢的体会闭包和 JSX
( JavaScript Syntax Extension
)对于组件的这种定义性,包括模板、事件及事件处理、状态、属性、计算量等等。如果您已经对此有一定的体会之后,我们可以更深入的思考这样的特性对于我们编写 React 组件有什么启示,我觉得其中最为重要的启示就是 “寓动于静” 的组件编写原则,下面我会详细的解释这个原则。
# “寓动于静” 的组件编写原则
# “万宗归静”
“寓动于静” 就是要回归静态,我称之为 “万宗归静”。
动与静本身就是一对矛盾体,动态的内容相比于静态内容,自然具有更高的复杂性。复杂性越高则意味着我们可能需要更高的代价去完成同等的任务。下面我将举几个例子来说明这个观点。
我们知道 Vue 中采用 SFC 的最重要的原因之一是 SFC 为组件的静态分析提供了便利。对于 ViewModel 而言,在追踪到副作用之后,如何以最小的代价根据副作用去更新视图就成了最为重要的优化方向之一。在 Vue 的响应式更新之中,除了采用更为高效的节点 DIFF 算法之外,从源头上减少节点的 DIFF 也是重要的优化手段。那么怎么减少需要 DIFF 的节点呢?SFC 的结构为 Vue 进行基于模板文件的组件的静态分析提供了便利,Vue 通过为模板节点打上静态标记以使 DIFF 的成本能够尽可能地降低。这是很好的手段,也是静态分析的优越性之一。
同 Vue 中 SFC 类似,React 组件的编写也应该尽可能保持静态。我这里说的静态并非是说减少动态的内容、减少页面的交互性,而是说减少组件顶层的动态内容。闭包的高效就体现在空间换时间的特点上。React 在渲染的每一 “帧” 都会重新执行闭包,即 Component(props, context)
,如果顶层内容包含了过多的计算成本,很显然就会阻塞 React 中视图的渲染。就一个 React 组件而言,可以静态的将之分成五个部分:
Props&State
: 属性、状态和计算量(props/state)等;Handler
: 事件处理函数或者业务处理函数;Effect
: 副作用部分;Render
: 视图模板;Hook
: Hook 可以包含State
、计算量
、Effect
和Handler
。注意:Hook
不应该包含视图。由此可知,Hook
本质上是对组件的Mixin
,因为可以包含Effect
使之比普通的Mixin
要更加的强大。这也是Hook
能够带来代码复用、逻辑复用的好处的原因。
你可以在编写组件时清晰的标明这些模块以使你的代码的阅读性更好。
现在我们从静态的视角来观察以上内容, Props&State
本身是变量,计算成本较小; Handler
是函数,如果没有包裹于 useCallback
中则会每次实例化,但是函数本身在闭包之中只在需要调用时执行,因此,应当按照依赖来决定是否需要实例化以减少成本; Render
部分经过 babel
处理变为 ReactElement
,成本较小,但是也需要考虑每次实例化模板时的计算成本,比如 inline handler
的问题; Effect
本质上是注册副作用,副作用在渲染时是批量执行的,因此只需控制好依赖项即可; Hook
本质上是对组件状态、副作用、业务逻辑等的混入,遵从上述原则即可。
可以看到,组件本身是静态的,只有在数据流的驱动下 React 渲染之后才引起视图的更新,然而,更新后的视图又何尝不是静态的呢?所以,组件在单一帧的内部是完全静态的,稳定的!React 在时间切片的末尾对视图的更新,既兼顾了性能,也兼顾了页面的稳定性!再看看时间切片的思想是否如此熟悉?计算机的时间切片、JavaScript 事件循环的时间切片、任务队列的时间切片、React 调度器的时间切片......
现在的问题就在于需要区分组件中哪些内容是顶层的,哪些内容是非顶层的?顶层的内容一定要遵循静态的规则,切不可插入高计算成本、高时间成本的内容,尤其是副作用,(副作用一定要包含在 useEffect
或者 useLayoutEffect
之中进行注册)。理解闭包的特性和 React 的原理或许对于理解这个问题有一定帮助。
如果这还不能证明 “万宗归静” 的正确性?我这还有更多的例子,比如:
- React 中对状态的收集(updateQueue)、对副作用的收集、对节点的 EffectTag 标记和冒泡等等。
- 相同环境下,迭代往往比递归更为高效。
- 在股市的分析中,往往也包括 “形态学” 和 “动力学”,但是 “动力学” 是通过 “形态学” 所表现的。
- ......
# “择机而动”
“寓动于静” 就是要使动态的内容 “择机而动”。
视图中绝不可能缺少动态的内容,主要原因是因为数据是动态的、交互也是动态的。动态的内容绝非不执行,也绝非立即执行,而是需要在适当的时间进行执行!这个适当的时间可能会有很多的场景,但最终目的是为了保证页面渲染的稳定性和高效性的平衡。
怎么样才能使动态的内容能够 “择机而动” 呢?有很多的方法可以达到目的,比如说事件监听机制、事件委托机制、观察者模式、受调度的任务队列等。要理解这个问题,就需要深刻思考两个概念:任务(或者说产品)、生产者与消费者。任务可以由生产中心所生产,由运输中心所运输,由任务中心所管理,由调度中心所调度,由执行中心所执行......。当然这样的想法很抽象,但却很有用。思考清楚这些问题,我们就能把动态的内容(包括静态的内容)进行 “任务化”(或者说 “产品化”),使之能够实现更加丰富的特性,如批量消费、包装(代理)、装饰、质检(健康检查)、过滤、去重、排序、热化(使某些产品优先被消费)、冷化(降低某些产品的消费权重)、再加工、防伪(摘要)、持久化、序列化和反序列化、暂停生产或者继续生产、暂停消费或者继续消费、分类(聚类)、优先级处理、适配器(新旧产品适配)...... 实际上,发挥你的想象力,你所能做到的远远不止是这些!
现在我要回归到 “择机而动” 的主题上来,我将从 React 中举例说明这个问题,这其中大部分问题都是优化的问题:
React 中的组件动态内容包含两个部分:
- 事件驱动:由事件系统所驱动的组件状态、行为的变化。包括点击事件(onclick)、聚焦事件(onfocus)、输入内容事件(oninput)等等。
- 副作用驱动:由副作用所驱动的组件的状态、行为的变化。包括网络请求、查询 localStorage 等等获取数据的行为、操作 DOM、添加订阅或者定时器、日志记录等其他可能影响组件状态的副作用。
我们可能已经注意到了,无论是事件还是副作用,我们在组件中只有注册行为!事件和副作用的执行后果可能是动态的,但是其注册行为本身是静态的,因为 React 可以保证注册的行为不会引起组件状态或者行为的更新!这便是 “寓动于静” 的体现。
现在让我们来着重体会 “时机” 这个词的重要性:
- 在 React 中注册的事件会立即执行吗?当然不是,应当等到事件真正发生才行!那么事件真正发生了之后就会立即执行吗?当然不是,应当等到事件冒泡到事件所委托的节点才会真正执行!如果把事件处理当做是一项任务,那么被委托到的节点
FiberRoot
又何尝不是 “事件处理中心”? - 在 React 中副作用的执行时机是什么时候呢?我们已经知道副作用都会在
useEffect
或者useLayoutEffect
中注册,那个两者的执行时机又是什么呢?事实上,调和器会在Commit
阶段的layout
步骤中执行useLayoutEffect
所注册的副作用,在渲染结束的的首次调度中执行useEffect
中注册的副作用。执行的时机不同才是两者针对副作用处理的最大的区别。重点是,两者执行的时机都是以一次渲染为粒度的,而且此处的副作用包含了组件中注册的所有同等类型的副作用,也就是说这不仅仅是时间分片上的批处理,而且是副作用任务本身的批处理!
现在你知道了 “寓动于静” 的原理和强大的特性了吧。动静是对立统一的,以静为动,寓动于静,可能会是帮助您解决复杂问题的一大利器。“风动幡动,仁者心动” 何尝不是此理。
# React 为什么使用 JSX?
# 构建 UI
原文
React 用了不同的方式构建 UI,把它们拆成组件。 这意味着 React 使用了一种真实的、具有各种特性的编程语言来渲染视图, 我们认为它相较于模板而言是一种优势的理由如下:
- JavaScript 是一种灵活、强大的编程语言,具有构建抽象的能力, 这在大型应用中非常重要。
- 通过将你的标记和其相对应的视图逻辑统一起来, React 实际上可以让视图变得更容易扩展和维护。
- 通过把一种对标记和内容的理解融入 JavaScript, 不用手动连接字符串,因此 XSS 漏洞的表面积也更小。
我认为 React 此处所指的模板是指使用模板文件的方式来构建组件、使用 HTML 和指令来构建视图的组件描述方式,如 SFC。狭义的理解模板应该是指视图模板,我认为 Vue 中 template 里 HTML 和指令所描述的视图是模板,React 中函数式组件中返回的 JSX 的方式(结合 HTML 和 JS)描述的视图也是模板,是以广义概念理解之。
# 渲染逻辑本质上与 UI 逻辑内在耦合
原文
- React 认为渲染逻辑本质上与其他 UI 逻辑内在耦合,比如,在 UI 中需要绑定处理事件、在某些时刻状态发生变化时需要通知到 UI,以及需要在 UI 中展示准备好的数据。
- React 将渲染逻辑和 UI 逻辑共同存放在称之为 “组件” 的松散耦合单元之中,来实现关注点分离。
对于组件而言,可以分成渲染逻辑和 UI 逻辑(也可以将样式包含在内),于 SFC 组件而言,区分则十分明显, template
、 script
和 style
分别与之对应。可见 React 对于组件的理解与 SFC 大为不同甚至可以说是刚好相反。至于组件是应该 “松散耦合” 还是应该 “解耦合”,我认为各有利弊。
至于为什么 React 会有这种 “松散耦合” 的概念,我认为很有可能是受到原生 DOM 的影响。如果分开从 DOM 层面和 VDOM 层面去思考,JSX 所表征的 ReactElement 在组件层面上更接近于是对 DOM 的描述。之所以这么说是因为 ReactElement 总归是相对静态的,它虽然是符合 VDOM 的概念定义的,但是 VDOM 的一大特征是动态性,VDOM 是需要引入和依赖于 DIFF 算法的。在 ReactElement 中其实不存在 DIFF 算法,它只被上层 DIFF 的结果所影响从而做出应有的改变。从这个角度来看,JSX 以 “松散耦合” 的理念降低其 VDOM 的特性而更加贴近于 DOM 的特性是正确的,这也是降低 DOM 更新成本、提升 VDOM 应用效率的必然要求。
还有一种问题是这种 “松散耦合” 所引入的,便是 Inline Functions
的问题。JSX 的灵活性允许我们在 React 中使用 Inline Functions
,但是其中另有区别。
# Inline Functions
不使用 Inline Functions
:
function Component() {
const handleClick = () => {handleThis();}
return <div onClick={handleClick}></div>
}
2
3
4
编译后:
function Component() {
const handleClick = () => {
handleThis();
};
return /*#__PURE__*/ React.createElement("div", {
onClick: handleClick
});
}
2
3
4
5
6
7
8
9
使用 Inline Functions
:
function Component() {
return <div onClick={() => {handleThis();}}></div>
}
2
3
编译后:
function Component() {
return /*#__PURE__*/ React.createElement("div", {
onClick: () => {
handleThis();
}
});
}
2
3
4
5
6
7
可以看出的是,行间的函数将会被编译到 ReactElement 之中。 Inline Functions
有如下两个最大的诟病:
- 频繁实例化,频繁的 GC。React 在每次渲染时都会重复实例化函数,旧的函数也会立即被 GC,尽管这是无效的工作;如果是事件处理器的话则会更加明显,因为事件处理器一般是静态的,而这种每次删除处理器而重新绑定的行为必然会造成一定的花销。
- 引用变化导致子组件无效的重新渲染。如果
Callback
是需要传递给子组件的话,使用Inline Functions
则会导致引用不断改变,造成子组件的 Memo 失效,进行无效的重新渲染。
注意,我们有 Function Ref
(回调 Refs,参见: 回调 Refs (opens new window)) 的用法,也可以会有 Inline Functions
的写法。这种情况应当另加讨论,因为 Ref
引用节点的 DOM 元素或者 forwardRef
,每次渲染时都会有删除引用和重新引用的操作,情形并与上相同,也不存在造成无效渲染的问题。
那么,是否应该放弃使用 Inline Functions
以规避以上的问题呢?(一些 Eslint 的规则提供了针对类组件的 Inline Functions
的检查,eslint-plugin-react/jsx-no-bind (opens new window))实则不然:
- FC 的本质是利用了闭包的封闭性以实现组件状态和属性的分发(注意:类组件基于 class 的写法本质上也是闭包!),类组件亦是如此。闭包模拟组件的好处在于其封闭性,即
闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。
这样的一个稳定的 Context 对于描述组件的属性和行为是十分有利的,但是别忘了闭包最大的缺点在于:
MDN: 闭包的性能考量
- 如果不是某些特定任务需要使用闭包,在其它函数中创建函数是不明智的,因为闭包在处理速度和内存消耗方面对性能具有负面影响。(关于这一点,React 与相关的观点:Hook 会因为在渲染时创建函数而变慢吗? (opens new window))
- 例如,在创建新的对象或者类时,方法通常应该关联于对象的原型,而不是定义到对象的构造器中。原因是这将导致每次构造器被调用时,方法都会被重新赋值一次(也就是说,对于每个对象的创建,方法都会被重新赋值)。
闭包最大的开销在于其实例化的开销,FC 在每次渲染时都会实例化,即使不使用 Inline Functions
,而是将处理函数写在顶层的闭包中,在闭包实例化之时,函数同样会有实例化的过程!这部分实例化的开销同样无法避免!
有一种可能性是将处理的函数包裹在 useCallback
中,但是需要注意的是, useCallback
和 useMemo
是一种反模式(anti-pattern)的写法,其本身也是有一定的花销的!
所以,关于是否需要使用 Inline Functions
实际上是一种可读性、可维护性和性能的权衡(tradeOff),在社区中也有多种声音呼吁不要过早的对 React 进行性能优化,因为你也不知道优化后的应用是否会有实质的性能提升。
参考:
- How To Use Inline Functions In React Applications Efficiently (opens new window)
- javascript - Need to understand inline function call with react FC - Stack Overflow (opens new window)
- React, Inline Functions, and Performance | by Ryan Florence | Medium (opens new window)
- When to useMemo and useCallback (opens new window)
- Don’t over-engineer – Page Fault Blog (opens new window)
# React 如何解释响应式更新?
原文
- 当你的组件首次初始化,组件的 render 方法会被调用, 对你的视图生成一个轻量化的表示。从那个表示生成一串标记, 并注入到文档中。当你的数据发生了变化, render 方法会再次被调用。为了尽可能高效地执行更新, 我们会对前一次调用 render 方法返回的结果和新的调用结果进行区分, 并生成一个要应用于 DOM 的最小更改集合。
- render 返回的数据既不是一串字符串也不是一个 DOM 节点 —— 而是一种表示 DOM 应该是什么样子的轻量化描述。我们把这个过程称为协调。因为这样的重渲染实在太快了, 所以开发者不需要显式地指定数据绑定。
- 在实践中,大多数 React 应用只会调用一次 root.render ()。参见:更新已渲染的元素 (opens new window)。
render 方法
:这里的 Render 方法必然不是指的是ReactDOM.render
(新版本为ReactDOM.createRoot.render
) 这样的方法,我认为应该指的是renderRootSync
和renderRootConcurrent
方法。因为前者是针对 HOST 环境所提供的 Render 方法,只会在首次渲染时执行一次,而后者则会在调度器回调之后执行,即数据发生了变化会再次被调用
。轻量化的表示
:指的是 FiberTree 系统,数据结构总归是轻量的
,它体现了视图最新的状态和行为以及视图稳定的状态和行为。一串标记
:指的是EffectTag List
,当然这里做过一些重构,但是总体思想未变,EffectTag
体现对节点将要执行的增、删、改(mutation)操作,最终将应用到 DOM 节点上。对前一次调用 render 方法返回的结果和新的调用结果进行区分
:指的是workInProgress FiberTree
和current FiberTree
调和的过程,当然也包括了节点的 DIFF 过程。应用于 DOM 的最小更改集合
:指的是经过上述 DIFF 过程之后对EffectTag List
的搜集过程。- 调和(或者协调)的过程是生成 DOM 的轻量化描述(VDOM)的过程。
# JSX 中 XSS 处理
原文
React DOM 在渲染所有输入内容之前,默认会进行转义。
在 JSX 中插入字符内容时是做了转义(Escape)处理的,即将可能产生风险的特殊字符进行转义,参见:JSX 防止注入攻击 (opens new window)。
相反的,在向 JSX 中插入 HTML 时,如使用 dangerouslySetInnerHTML
时,React 并没有帮你将待插入的内容进行消毒,需要自行进行内容消毒。可以使用 jam3/no-sanitizer-with-danger
、theodo/RisXSS: RisXSS (opens new window) 规则以确保在使用 dangerouslySetInnerHTML
时正确的进行了消毒处理。
原文
"The prop name dangerouslySetInnerHTML is intentionally chosen to be frightening. ..."
"After fully understanding the security ramifications and properly sanitizing the data..."
使用如下的 sanitizer
处理消毒:
YahooArchive/xss-filters: Secure XSS Filters. (opens new window);
javascript - React.js: Set innerHTML vs dangerouslySetInnerHTML - Stack Overflow (opens new window)
# ReactElement 与 DOM Element
原文
- 元素是构成 React 应用的最小砖块。
- 元素描述了你在屏幕上想看到的内容。
- 与浏览器的 DOM 元素不同,React 元素是创建开销极小的普通对象。
- React DOM 会负责更新 DOM 来与 React 元素保持一致。
JSX 的元素,即 ReactElement,描述了最小视图渲染单元,其本质上是 JavaScript 对象所描述的虚拟节点,与 DOM 元素完全不同。简单来说,我们把 ReactElement 描述为 VDOM,尽管它并没有实质性的 DIFF 过程。
VDOM 最大的特点是提供了开销较小的节点的逻辑抽象,这是众多的基于 VDOM 和 DIFF 算法理念以实现节点的响应式更新的框架的理论来源。同时,VDOM 本身也是防腐层(源于其抽象性),抹平了各种 HOST 环境的实现上的差异性。
ReactElement 将更新 DOM 使其与自己保持一致,这种更新是局部的、批量的,它以标记的形式展开。注意,这里所说的 其与自己保持一致
并非 ReactElement 与 DOM 与直接的关联,中间还需经过 FiberTree 的调和过程。
# React 元素是不可变对象
原文
React 元素是不可变对象。一旦被创建,你就无法更改它的子元素或者属性。一个元素就像电影的单帧:它代表了某个特定时刻的 UI。
ReactElement 是静态的,ReactElement 是不可变对象!
要理解这个问题,先得理解前文所写的 React 渲染中的 “帧” 的概念,基于此,如果我们狭义的理解此 “帧” 即为 “视图帧” 的话,那么这里的 “帧” 就是通过 ReactElement 所表现的!
ReactElement 在 React 整个渲染循环中充当什么样的角色呢?ReactElement 提供了用户对想要的视图所有的静态的和可交互的描述和定义,这种组件的定义形式因 “寓动于静” 的原理而成为具有不同的状态和行为的视图的 “帧”。
ReactElement 在一次渲染之中是寂静的、静态的,它依托闭包的封闭特性而将此时此时的状态、属性、行为方式进行分发,或在视图中呈现,或成为视图交互时的行为绑定,这种静态所体现的正是组件或者组件视图的 “帧”。
现在我们且聊一聊 “帧” 是如何被渲染的,由 ReactElement 所描述的静态的帧是如何成为屏幕的视图呈现的呢?
React 组件的状态和行为模式是在 React 调和中的 Batch
执行阶段被收集的,在这个阶段是页面视图最为稳定的时候,一个新的渲染或许已经被调度,但是被没有被调度器所回调。现在我们假设一个新的渲染回调将产生,现在浏览器将有足够的精力和时间去处理渲染的任务。在确认执行渲染任务时,组件的状态和行为模式将无法被继续收集,但是别担心,这个过程将会很短暂,并且渲染的任务是可以打断的,如果有高优先级的交互更新出现,我们可以给此次渲染按下暂停键。
现在假设这次渲染并没有被打断,那个 React 将会去获取组件新的状态和行为模式了,ReactElement 将会被注入最新的状态和行为模式并且交给 React,这个过程正是通过闭包实现的。现在 React 通过 JSX 脱水了组件的信息量,那么这个信息量将如何被最小化的执行呢?双缓存的 FiberTree 的结构正是为此而生!
我们从 ReactElement 中脱水的信息量必须要经过处理和计算,才能降低更新所带来的高成本。刚才的分析还是从组件的层面来展开,现在我们将视角扩展对整个应用。应用是由组件树所构成的,组件又是由 ReactElementTree 所构成的。React 并不从组件层面进行更新的原则决定了 React 必须对这样的树形结构采取更为有效的、低成本的数据结构进行处理。于是 Fiber 便产生了,从应用的容器开始,到每一个叶子节点,React 将之处理为一棵由 Fiber 节点所构成的树形结构,而且为了提升 DFS 的效率,除了 child
指针之外,FiberTree 还采用了 parent
指针和 sibling
指针。
React 将从 ReactElement 中获取的信息量注入到 Fiber 节点之中,通过这种方式,React 对节点有了更多的控制权,这使得很多激动人心的功能得以实现。如果是首次渲染的话,构建这样的一棵 FiberTree 确实将花费不菲,但是幸运的是 root.render
只会在应用挂载时执行一次,并且首次渲染也避开了 DIFF 过程节省了开销。
如果是非首次渲染的话,那么内存中便存在两棵 FiberTree,其中一棵是表征当前正在显示在页面中的稳定版本的 FiberTree,我们可以理解为上一 “帧”,而另外一棵树则是要通过复用节点所构建的包含组件树最新状态和行为模式的 FiberTree,我们可以理解为下一 “帧”。下面便是相邻的两 “帧” 的 DIFF 过程了,这是一个捕获和冒泡的过程,这样的过程使我们知道了新的一 “帧” 需要有哪些 “改动点”,而这样的 “改动点” 的集合便构成了 “应用于 DOM 的最小更改集合”。至此, Render
过程便结束了。值得注意的是, Render
过程是在浏览器的空闲时间中完成的,而且此过程不会对当前所渲染的页面造成任何不稳定的影响!
下面 Commit
阶段便开始了,React 在 mutation
阶段对以上的 “改动点集合” 进行了处理,从 fiber 节点中我们将创建、更新或者删除 DOM 节点的实例,两棵 FiberTree 也将交换自己的 “角色”。之后浏览器将在空闲时间对 DOM 树进行重排和重绘。这便是新的一 “帧” 真正落地的时刻。
通过这样一个 “帧” 的渲染的过程,现在请你告诉我为什么 “React 元素是不可变对象”?因为 ReactElement 从被 “截取” 那一刻起,它必须是静态的、可分析的,因为 React 正是通过这个的 “帧” 的 “采样”,来实现其内部的渲染机制!