![](https://img.haomeiwen.com/i25473367/2e45cac10eeb30e2.png)
有没有想过当调用ReactDOM.render(<App />, document.getElementById('root'))
时会发生什么?
我们知道ReactDOM在后台构建DOM树并将应用程序呈现在屏幕上。但是React实际上是如何构建DOM树的呢?当应用程序状态更改时,它如何更新树?
在本文中,我将首先说明React 15.0.0之前React是如何构建DOM树,以及该方式的缺陷,以及React 16.0.0的新模型如何解决这些问题。这篇文章将涵盖广泛的概念,这些概念纯粹是内部实现细节,对于使用React进行实际的前端开发并不是严格必需的。
栈解调器 Stack reconciler
让我们从我们熟悉的开始。ReactDOM.render(<App />, document.getElementById('root'))
ReactDOM模块将把它传递给解调器。这里有两个问题:<App/ >
-
<App />
指向什么 - 什么是解调器
reconciler
?
让我们解开这两个问题。
<App />
是一个React元素,“元素描述了树”。换句话说,元素不是实际的DOM节点或组件实例。它们是一种向React描述他们是什么样的元素,他们拥有什么属性以及他们的孩子是谁的方式。
这就是React真正力量所在。React本身就消除了如何构建,渲染和管理实际DOM树的生命周期的所有复杂部分,从而有效地简化了开发人员的工作。要了解其真正含义,让我们看一下使用面向对象概念的传统方法。
在典型的面向对象的编程世界中,开发人员需要实例化和管理每个DOM元素的生命周期。例如,如果您想创建一个简单的表单和一个提交按钮,那么状态管理甚至是简单的操作都需要开发人员的努力。
假设Button
组件具有状态变量isSubmitted
。Button
组件的生命周期类似于下面的流程图,其中每个状态都需要由应用程序处理:
![](https://img.haomeiwen.com/i25473367/71cab95f3ca859a3.png)
流程图的大小和代码行数随着状态变量数的增加而呈指数增长。
React具有恰好可以解决此问题的元素。在React中,有两种元素:
-
DOM元素: 当元素的类型是字符串时,例如,
<button class="okButton"> OK </button>
-
组件元素: 当类型是类或函数时,例如
<Button className="okButton"> OK </Button>
,其中<Button>
是类或函数组件。这些是我们通常使用的典型React组件
重要的是要了解这两种类型都是简单的对象。它们仅仅是对需要在屏幕上呈现的内容的描述,实际上在创建和实例化它们时不会导致任何呈现。这使得React可以更轻松地解析和遍历它们以构建DOM树。遍历完成后,才会进行实际渲染。
当React遇到一个类或函数组件时,它将询问该元素它根据其属性将其呈现给哪个元素。例如,如果<App>
组件呈现了以下内容:
<Form>
<Button>
Submit
</Button>
</Form>
然后,React将根据其相应的props询问<Form>
和<Button>
组件将其渲染成什么内容。例如,如果该Form
组件是如下所示的函数组件:
const Form = (props) => {
return(
<div className="form">
{props.form}
</div>
)
}
React将调用render()
以知道它呈现了哪些元素,并最终将看到带有子元素的div
。React将重复此过程,直到知道页面上每个组件的基础DOM标签元素为止。
递归遍历一棵树以了解React应用程序组件树的底层DOM标签元素的确切过程称为解调
。在解调结束时,React知道了DOM树的结果,并且像react-dom或react-native这样的渲染器应用了更新DOM节点所需的最小更改集
因此,这意味着当您调用ReactDOM.render()
或setState()
时,React将执行解调。在setState
的情况下,它将执行遍历并通过将新树与渲染的树进行区分来找出树中发生了什么变化。然后,将那些更改应用于当前树,从而更新与调用相对应的状态.
现在我们了解了解调是什么,让我们看一下该模型的陷阱。
哦,对了-为什么将此称为“栈”解调器?
该名称源自“栈”数据结构,该结构是一种后进先出的机制。栈与我们刚刚看到的内容有什么关系?好吧,事实证明,由于我们有效地进行了递归,因此它与栈有关。
递归
为了理解为什么会发生这种情况,让我们举一个简单的例子,看看call stack发生了什么。
function fib(n) {
if (n < 2){
return n
}
return fib(n - 1) + fib (n - 2)
}
fib(10)
![](https://img.haomeiwen.com/i25473367/ab9c04a8b0cf1b0f.png)
如我们所见,调用栈将每个调用推入栈,直到弹出为止,这是要返回的第一个函数调用。然后,它继续推送递归调用,并在到达return语句时再次弹出。这样,它有效地使用了调用栈,直到返回为止,并成为从栈中弹出的最后一个元素。fib()``fib(1)``fib(3)
我们刚刚看到的解调算法是纯递归算法。更新导致立即重新渲染整个子树。尽管这很好用,但仍有一些局限性。正如安德鲁·克拉克(Andrew Clark)所说:
- 在用户界面中,不必立即应用每个更新。实际上,这样做会很浪费,导致帧下降并降低用户体验
- 不同类型的更新具有不同的优先级-动画更新需要比数据存储中的更新更快地完成
现在,当我们说“丢帧”时,我们指的是什么?为什么递归方法会有问题呢?为了掌握这一点,让我从用户体验的角度简要说明什么是帧速率以及为什么它很重要。
帧频是指连续的图像出现在显示器上的频率。我们在计算机屏幕上看到的所有内容都是由屏幕上播放的图像或帧组成,这些图像或帧的显示速度瞬间达到了眼睛。
要理解这是什么意思,可以将计算机显示屏视为一本翻页书,而将翻页书的页面视为翻页时以一定速率播放的帧。换句话说,计算机显示器不过是一本自动翻页书,当屏幕上的事物发生变化时,它会一直播放。
通常,为了使视频感觉到人眼平滑且瞬间,视频需要以每秒30帧(FPS)的速率播放。高于此值将提供更好的体验。这就是为什么游戏玩家喜欢第一人称射击游戏时更高的帧频的主要原因之一,而精确度非常重要。
话虽这么说,如今大多数设备以60 FPS刷新屏幕-换句话说,就是1/60 = 16.67ms,这意味着每16ms就会显示一个新帧。这个数字非常重要,因为如果React渲染器花费16毫秒以上的时间在屏幕上渲染某些东西,浏览器将丢弃该帧。
但是,实际上,浏览器需要执行一些类似客房整理的工作,因此您的所有工作都需要在10毫秒内完成。如果您无法满足此预算,则帧速率会下降,并且屏幕上会显示内容抖动,它会对用户的体验产生负面影响。
当然,这不是引起静态和文本内容关注的主要原因。但在显示动画的情况下,此数字至关重要。因此,如果App
每次有更新时,React协调算法遍历整个树并将其重新渲染,并且如果遍历花费的时间超过16ms,则将导致丢帧,并且丢帧是不好的。
这是为什么最好按优先级对更新进行分类,而不是盲目地将传递给解调器的每个更新都应用的重要原因。另外,另一个不错的功能是能够在下一帧中暂停和恢复工作。这样,React可以更好地控制其渲染时使用的16ms预算。
这导致React团队重写了解调算法,新算法称为Fiber。我希望现在对于Fiber的存在方式和原因以及它的重要性有道理。让我们看一下Fiber如何解决这个问题。
Fiber的工作原理
现在我们知道了促使Fiber发展的动力,让我们总结实现Fiber的必要功能。
再次,我指的是安德鲁·克拉克(Andrew Clark)的注释:
- 为不同类型的工作分配优先级
- 暂停工作,稍后再返回
- 如果不再需要中止工作
- 重用先前完成的工作
实现这样的事情的挑战之一是JavaScript引擎如何工作以及在某种程度上缺乏该语言中的线程。为了理解这一点,让我们简要地探讨一下JavaScript引擎如何处理执行上下文。
JavaScript执行栈
每当您使用JavaScript编写函数时,JS引擎都会创建所谓的函数执行上下文。此外,每次JS引擎启动时,它都会创建一个包含全局对象的全局执行上下文,例如,window
浏览器中的global
对象和Node.js中的对象。这两个上下文在JS中使用栈数据结构(也称为执行栈)进行处理。
因此,当您编写如下内容时:
function a() {
console.log("i am a")
b()
}
function b() {
console.log("i am b")
}
a()
JavaScript引擎首先创建一个全局执行上下文,并将其推入执行栈。然后,它为函数a()
创建函数执行上下文。由于b()
在a()
内部调用,它将为b()
创建另一个函数执行上下文并将其推入栈。
当函数b()
返回时,引擎将破坏b()
的上下文,而当我们退出函数a()
时,将破坏a()
的上下文。执行期间的栈如下所示:
![](https://img.haomeiwen.com/i25473367/fdd72f75fda467f2.png)
但是,当浏览器发出诸如HTTP请求之类的异步事件时,会发生什么?JS引擎是否存储执行栈并处理异步事件,或者等到事件完成?
JS引擎在这里做了一些不同的事情。在执行栈的顶部,JS引擎具有队列数据结构,也称为事件队列。事件队列处理进入浏览器的异步调用,例如HTTP或网络事件。
![](https://img.haomeiwen.com/i25473367/556b20b6ca9a2abf.png)
JS引擎处理队列中内容的方式是等待执行栈变空。因此,每次执行栈变空时,JS引擎都会检查事件队列,将元素弹出队列,然后处理该事件。重要的是要注意,JS引擎仅在执行栈为空或执行栈中唯一的元素是全局执行上下文时才检查事件队列。
尽管我们将它们称为异步事件,但这里有一个细微的区别:事件相对于它们何时进入队列是异步的,但是相对于它们实际得到处理的时间而言,它们并不是真正的异步。
回到我们的栈解调器,当React遍历树时,它正在执行栈中执行。因此,当更新到达时,它们到达事件队列(某种)中。并且只有当执行栈为空时,更新才会得到处理。这正是Fiber通过使用算法机制几乎重新实现栈(暂停和恢复,中止等)来解决的问题。
在这里再次引用Andrew Clark的注释:
“Fiber是重新实现专门用于React组件的栈,您可以将单根Fiber视为虚拟栈框架。
重新实现栈的优点是,您可以将栈帧保留在内存中,并根据需要(以及在任何时候)执行它们。这对于实现我们计划的目标至关重要。
除了调度之外,手动处理栈帧还可以释放诸如并发和错误边界之类的功能。”
简单来说,Fiber代表具有其自己的虚拟栈的工作单元。在解调算法的先前实现中,React创建了一个不可变的对象树(React元素),并递归遍历该树。
在当前的实现中,React创建了一个可以改变的Fiber节点树。Fiber节点有效地保存了组件的状态,属性以及它渲染到的基础DOM元素。
而且由于Fiber节点可以被改变,因此React不需要重新创建每个节点来进行更新-它可以在存在更新时简单地克隆和更新该节点。另外,在使用Fiber树的情况下,React不会进行递归遍历。而是创建一个单链表,并进行父级优先,深度优先的遍历。
Fiber节点单链接列表
Fiber节点代表栈框架,但也代表React组件的实例。Fiber节点包括以下成员:
Type
<div>
,<span>
等用于宿主组件(字符串),而类或函数则用于复合组件。
Key
与传递给React元素的键相同。
Child
表示当我们调用组件时返回的元素。例如:render()
const Name = (props) => {
return(
<div className="name">
{props.name}
</div>
)
}
<Name>
的子项是<div>
,因为它返回一个<div>
元素。
Sibling 兄弟
表示render
返回元素列表的情况。
const Name = (props) => {
return([<Customdiv1 />, <Customdiv2 />])
}
在上述情况下,<Customdiv1>
和<Customdiv2>
是的子代<Name>
,即的父代。这两个孩子形成一个单链表。
Return
表示返回栈帧,从逻辑上讲,它是返回到父Fiber节点的返回。因此,它代表父代。
pendingProps
和 memoizedProps
记忆化意味着存储函数执行结果的值,以便以后可以使用它,从而避免重新计算。pendingProps
表示传递给组件的props,并memoizedProps
在执行栈的末尾进行初始化,并存储该节点的props。
当输入pendingProps
等于时memoizedProps
,它表示可以重新使用Fiber的先前输出,从而避免了不必要的工作。
pendingWorkPriority
一个数字,指示Fiber代表的工作优先级。该ReactPriorityLevel
模块列出了不同的优先级及其代表的含义。除之外NoWork
,它为零,数字越大表示优先级越低。
例如,您可以使用以下功能来检查Fiber的优先级是否至少与给定级别一样高。调度程序使用优先级字段来搜索要执行的下一个工作单元。
function matchesPriority(fiber, priority) {
return fiber.pendingWorkPriority !== 0 &&
fiber.pendingWorkPriority <= priority
}
备用 Alternate
在任何时候,一个组件实例最多具有两个与其对应的Fiber:当前Fiber和进行中的Fiber。当前Fiber的备用Fiber是进行中的Fiber,而正在进行的Fiber备用是当前Fiber。当前的Fiber表示已经渲染的内容,而从概念上讲,正在进行的Fiber是尚未返回的栈帧。
输出
React应用程序的叶节点。他们是特定于渲染环境(例如,在浏览器的应用程序,它们是div
,span
等)。在JSX中,它们使用小写标签名称表示。
从概念上讲,Fiber的输出是函数的返回值。每个Fiber最终都有输出,但是输出仅由主机组件在叶节点上创建。然后将输出传送到树上。
最终将输出提供给渲染器,以便可以将更改刷新到渲染环境。例如,让我们看一下代码如下的应用程序的Fiber树外观:
const Parent1 = (props) => {
return([<Child11 />, <Child12 />])
}
const Parent2 = (props) => {
return(<Child21 />)
}
class App extends Component {
constructor(props) {
super(props)
}
render() {
<div>
<Parent1 />
<Parent2 />
</div>
}
}
ReactDOM.render(<App />, document.getElementById('root'))
![](https://img.haomeiwen.com/i25473367/f580331dc41764a8.png)
我们可以看到,Fiber树由相互链接的子节点的单链接列表(同级关系)和父子关系的链接列表组成。可以使用深度优先搜索遍历该树。
渲染阶段
为了理解React如何构建这棵树并在其上执行协调算法,我决定在React源代码中编写一个单元测试,并附加一个调试器来跟踪该过程。
如果您对此过程感兴趣,请克隆React源代码并导航到该目录。添加一个Jest测试并附加调试器。我编写的测试是一个简单的测试,基本上可以显示带有文本的按钮。当您单击按钮时,应用程序将销毁按钮并<div>
使用不同的文本呈现,因此此处的文本是状态变量。
'use strict';
let React;
let ReactDOM;
describe('ReactUnderstanding', () => {
beforeEach(() => {
React = require('react');
ReactDOM = require('react-dom');
});
it('works', () => {
let instance;
class App extends React.Component {
constructor(props) {
super(props)
this.state = {
text: "hello"
}
}
handleClick = () => {
this.props.logger('before-setState', this.state.text);
this.setState({ text: "hi" })
this.props.logger('after-setState', this.state.text);
}
render() {
instance = this;
this.props.logger('render', this.state.text);
if(this.state.text === "hello") {
return (
<div>
<div>
<button onClick={this.handleClick.bind(this)}>
{this.state.text}
</button>
</div>
</div>
)} else {
return (
<div>
hello
</div>
)
}
}
}
const container = document.createElement('div');
const logger = jest.fn();
ReactDOM.render(<App logger={logger}/>, container);
console.log("clicking");
instance.handleClick();
console.log("clicked");
expect(container.innerHTML).toBe(
'<div>hello</div>'
)
expect(logger.mock.calls).toEqual(
[["render", "hello"],
["before-setState", "hello"],
["render", "hi"],
["after-setState", "hi"]]
);
})
});
在初始渲染中,React创建一个当前树,该树是最初被渲染的树。
createFiberFromTypeAndProps()
是使用来自特定React元素的数据创建每根ReactFiber的函数。当我们运行测试时,在此函数处放置一个断点,并查看调用栈,它看起来像这样:
![](https://img.haomeiwen.com/i25473367/c926dc513f57cd99.png)
如我们所见,调用栈回溯到render()
,然后到createFiberFromTypeAndProps()
。还有一些其他的功能,这里是我们感兴趣的:workLoopSync()
performUnitOfWork()
和beginWork()
function workLoopSync() {
// Already timed out, so perform work without checking if we need to yield.
while (workInProgress !== null) {
workInProgress = performUnitOfWork(workInProgress);
}
}
React从workLoopSync()
开始建立树,从<App>
节点开始并递归到<App>
的子节点如<div>
和<button>
。workInProgress
持有一个有工作要做下一个Fiber节点的引用。
performUnitOfWork()
将Fiber节点作为输入参数,获取节点的备用节点,然后调用beginWork()
。这等效于在执行栈中开始执行函数执行上下文。
当React构建树时,beginWork()
引导到createFiberFromTypeAndProps()
并创建Fiber节点即可。React递归执行工作,并最performUnitOfWork()
终返回null,表明它已经到达树的末尾。
现在,当调用instance.handleClick()
时,意味着单击按钮并触发状态更新,会发生什么?在这种情况下,React遍历Fiber树,克隆每个节点,并检查它是否需要在每个节点上执行任何工作。当我们看一下这种情况的调用栈时,它看起来像这样:
![](https://img.haomeiwen.com/i25473367/a1df22d9df8c8f34.png)
尽管在第一个调用栈中没有看到completeUnitOfWork()
和completeWork()
,我们可以在这里看到它们。就像beginWork()
和performUnitOfWork()
一样,这两个函数执行当前执行的完成部分,这实际上意味着返回到栈。
正如我们所看到的,这四个功能一起执行执行工作单元的工作,并且还控制当前正在完成的工作,而这恰恰是栈协调程序所缺少的。从下图可以看出,每个Fiber节点都由完成该工作单元所需的四个阶段组成。
![](https://img.haomeiwen.com/i25473367/490d092c2ba130d4.png)
重要的是在此处注意,每个节点只有在其子级和同级节点返回completeWork()
之后才移动到completeUnitOfWork()
。例如,<App/>
以performUnitOfWork()
和beginWork()
为开头,然后移至Parent1的performUnitOfWork()
和beginWork()
,依此类推。当<App/>
的所有子项上的工作完成后<App/>
才会完成并返回。
这是React完成其渲染阶段的时间。基于click()
更新新构建的树称为workInProgress
树。这基本上是等待渲染的草稿树。
提交阶段
渲染阶段完成后,React进入提交阶段,在此阶段,它交换当前树和workInProgress
树的根指针,从而有效地将当前树与其基于click()
更新构建的草稿树交换。
![](https://img.haomeiwen.com/i25473367/918bfeb1bab4dcc5.png)
不仅如此,React还可以在将指针从Root交换到workInProgress
树之后重用旧的电流。此优化过程的最终结果是从应用程序的先前状态到下一状态以及下一状态的平滑过渡,依此类推。
那16ms的帧时间呢?React有效地为每个正在执行的工作单元运行一个内部计时器,并在执行工作时不断监视此时间限制。时间一到,React就会暂停当前正在执行的工作单元,将控件移回主线程,并让浏览器呈现此时完成的所有内容。
然后,在下一帧中,React从它停下来的地方开始,继续构建树。然后,如果有足够的时间,它将提交workInProgress
树并完成渲染。
网友评论