编辑推荐: |
本文主要从栈调和(Stack
reconciler),递归,Fiber 是如何工作的,JavaScript 执行栈,fiber
节点的单向链表,Render 阶段六个方面进行介绍。
本文来自于前端搬运工
,由火龙果软件Alice编辑推荐。 |
|
栈调和(Stack reconciler)
让我们从最熟悉的 ReactDOM.render(<App />, document.getElementById('root'))
开始。
ReactDOM 模块传递 <App /> 给调和器(reconciler)。这里有两个问题?
<App/> 指的是?
调和器(reconciler)是什么?
让我们看下这两个问题。
<App /> 是一个 React 元素, “描述树的元素.”
“一个元素是一个原生的对象描述一个组件实例或者DOM 节点及需要的属性” – React Blog
换句话说,元素并不是真实的 DOM 节点或者组件实例;它们是为了向 React 描述元素是什么类型,拥有哪些属性,及子元素是什么。
这是 React 的真正厉害的地方。React 抽象了所有复杂的地方,由 React 负责如何创建树,渲染树,管理真实
DOM 树的生命周期,让开发者更轻松些。为了理解上句话什么意思,让我们看下使用面向对象概念的传统的方法。
在典型的面向对象编程领域,开发者需要实例化和管理每个 DOM 元素的生命周期。例如,如果你想创建一个简单的表单和一个提交按钮,即使这么简单的应用,状态管理也需要开发人员的努力。
假设 Button 组件有个状态变量,isSubmitted。Button 组件的生命周期看起来像下面这样,每一个状态都需要应用顾及到:
流程图的大小和代码行的数量随着变量增加指数级的增长。
拥有元素的 React 精确地解决了这个问题。在 React 中有两种元素类型:
DOM 元素: 当元素的类型是字符串的时候, 比如, <buttonclass="okButton">OK</button>
组件元素: 当元素类型是一个类或者函数的时候, 比如, <ButtonclassName="okButton">OK</Button>,
<Button> 是一个类或者函数。这些是我们平时使用的最常见的 React 组件。
理解两种类型是简单的对象非常重要。他们仅仅描述渲染到屏幕上面的是什么,当创建和实例化它们的时候不会引起任何的渲染发生。对于
React 来说很容易的解析和遍历它们然后创建DOM 树。当遍历结束的时候,真实的渲染才发生。
当 React 遇见一个类或者函数组件的时候,它将会看看基于 props,这些组件需要渲染哪些元素。例如,<App
/> 组件渲染下面这些:
<Form> <Button>
Submit </Button>
</Form> |
React 会继续看看 <From> 和 <Button> 组件基于相应的
props,渲染什么。例如,如果 Form 组件是一个像下面这样的函数组件:
const Form = (props) => {
return( <div className="form">
{props.form} </div>
)
} |
React 将会调用 render() 知道它会渲染什么元素并且将会最终会看到它渲染的是一个带有一个
child 的 <div>。React 将会重复这个过程直到明确页面中的每个组件中潜在
DOM 元素。
当组件的 state 或 props 更改时,React通过比较新返回的元素和先前呈现的元素来决定是否需要更新实际的DOM。当它们不相等时,React
会找出最小变化集合,然后更新实际 DOM。这个过程叫做“调和”。
现在我们知道了什么是调和,让我们看下这个模式的缺陷。
顺便说下 - 为什么被称为“栈”调和器呢?
这个名称衍生于“栈”这个数据结构,“栈”是一个后入先出的机制。栈和我们刚说的有什么关系?好吧,事实证明,当我们递归的时候,事实上就是利用
栈来递归的。
递归
为了理解为什么是这样,让我们看一个简单的例子,然后看下在栈调用中会发生什么。
function fib(n) {
if (n < 2){
return n
}
return fib(n - 1) + fib (n - 2)
}
fib(10) |
正如我们看到的,调用栈将每一次 fib() 调用都压入了栈,fib(1) 是第一个返回的调用的函数。然后继续将递归的调用压入栈,遇到
return 语句再出栈。通过这种方式,可以有效地使用调用栈知道 fib(3) 返回,称为最后一个出栈的函数。
我们刚看到的调和算法是纯粹的递归算法。一个更新导致子树立马被重新渲染。这工作的很好,但是有些局限性。像
Andrew Clark notes 说的:
在 UI 中,并不是每次更新都要立马被应用;实际上,这样做可能是一种浪费导致丢帧和降低用户体验
不同类型的更新有不同的优先级 - 一个动画更新要优于从数据存储中更新。
当我们说到丢帧时指的是什么,当使用递归方法是为什么是一个问题?为了理解,让我简短的介绍下帧率和为什么对于用户体验如此重要。
帧率是在显示器上一定时间内图像的渲染次数。所有我们在电脑上面看到画面都是图片或者帧在显示器上间隔的时间段内连续出现在我们的眼睛中留下的视觉影像。
很典型地,这个视频人眼看起来很流畅,这个视频是的帧率是 30 FPS。比30 高的帧率就会带来更好地体验。这就是为什么游戏玩家玩第一人称设计游戏时期望更高的帧率,实时性精度非常重要。
话虽如此,大多数的设备刷新频率是 60FPS - 换句话说,1/60 = 16.67ms,意味着每隔
16ms 一帧。这个数字非常重要,因为如果 React 渲染的时间超过 16 ms,浏览器就会丢帧。
实际上,浏览器也有自己的工作要做,因此代码的运行时间需要控制在 10ms 以内。当你超过了这个预算,就会出现丢帧,动画会出现抖动。通常被称为
“jank”,严重影响用户的体验。
当然,对于现实静态图片或者文字没什么问题。但是为了显示动画,这个数字就变成运行时间的临界值了。因此如果
React 调和算法在每次更新都遍历整个 App 树,然后渲染,如果遍历的时间超过 16ms, 就会导致丢帧,你知道丢帧并不太好,会导致动画卡顿,不连贯。
这就是赋予更新任务的优先级非常好的一个大的原因,而不是盲目应用每次更新到调和器。另一个非常的特性是可以暂停,在下一帧恢复。这种方式
React 可以在 16ms 内更好地控制任务渲染的时间。
这导致了 React 团队重写了调和算法,新的算法被称为 Fiber。我希望到目前为止你明白了 Fiber
为什么存在及存在的意义。让我们通过探索 Fiber 的工作方式来解决这个问题。
Fiber 是如何工作的
现在我们知道了开发 fiber 的动机,让我们总结下需要实现的特性。
再一次引用 Andrew Clark’s 的笔记:
为不同类型的任务赋予优先级
任务的暂停与恢复
如果任务不再需要,可以中止
复用之前已完成的工作
实现这些特性的挑战之一是,JavaScript 引擎的工作方式,还有在语言中缺乏对线程的支持。为了理解,简短地探索下
JavaScript 引擎如何处理执行上下文的。
JavaScript 执行栈
无论什么时候在 JavaScript 中写一个函数,js 引擎就会相应地创建一个函数执行上下文,另外
JS 引擎开始工作的时候,会创建一个安居的执行上下文保存全局的对象 —— 例如,浏览器中的 window
对象,Nodejs 中的 global 对象。这两种上下文在 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() 的上下文环境也会被销毁。在执行器件栈如下图:
但是当浏览器发起如 HTTP 请求的异步事件会发生什么?JS 引擎保留执行上下文栈吗,然后处理异步事件,或者直到事件结束?
JS 引擎在这里的处理方式不太一样。在执行栈顶部,有一个队列的数据结构,也被称为事件队列。事件队列处理进入浏览器的HTTP
或者 网络事件。
JS 引擎处理队列的方式是通过等待执行栈为空的时候。因此一旦执行栈为空了,JS 引擎就会检查事件队列,出队,然后处理那个事件回调。注意,JS
引擎只有在执行栈为空或者在执行栈中只有全局执行上下文的时候才回去检查事件队列。
尽管我们调用这些异步事件,这里有一个微妙的区别:当这些事件处理函数到达队列的时候,这些事件是异步的,但是在他们被处理的时候并不是真正的异步。
回到我们的栈调和器,当 React 遍历树的时候,在执行栈中进行的。因此当接受到更新的时候,他们到达事件队列(排列好的)。只有当执行栈为空的时候,这些更新才会被处理。这恰恰是
fiber 通过近乎重新实现强大的能力的堆栈来解决的问题 - 暂停,恢复,丢弃等。
这里再一次引用 Andrew Clark 的笔记:
“Fiber 是专门为 React 组件,栈的重新实现。你可以把单个 fiber 看做一个虚拟的栈帧。
重新实现栈的优势是你可以在内存中追踪栈帧,然后以你期望的时间或者方式来执行他们。
对于实现目标调度是重要的。
除了调度以外,手动处理栈帧解锁并发和错误边界(error bundaries)等潜在的特性。在未来我们会涵盖这一部分。”
简而言之,一个fiber 就代表一个携带虚拟栈帧的任务单元。在之前的调和算法的实现中,React 创建了一个不可变得对象(React
元素)树,然后递归的遍历它们。
在当前的实现中,React 创建了一个可以改变的 fiber 节点树。fiber 节点保留了组件的
state, props, 和潜在的需要渲染到的目标 DOM 元素。
因为 fiber 节点是可以改变的,React 不需要每次更新都为每个节点重新创建一遍 - 它可以简单地克隆,当有更新的时候更新这个节点。另外再
fiber 树的情况下,React 不用递归遍历;相反,它创建了一个单向链表和父到子,深度优先的遍历。
fiber 节点的单向链表
一个 fiber 节点就代表一个栈帧,但是也代表一个 React 组件的一个实例。一个 fiber
节点有下列成员组成:
Type
<div>, <span>, 等. 宿主组件 (string), 类或者函数复合组件。
Key
和我们向 React 元素传入的 key 一样。
Child
代表当我们在组件上面调用 render() 后返回的元素。例如:
const Name = (props) => {
return( <div className="name">
{props.name} </div>
)
} |
<Name> 组件的 child 就是返回的 <div>。
Sibling
代表 render() 返回一系列的元素的情况。(指向下一个兄弟节点)
const Name = (props) => {
return([<Customdiv1 />, <Customdiv2 />])
} |
在上面的例子中,<Customdiv1> 和 <Customdiv2> 是
<Name> 的孩子,两个孩子形成一个单链表。
Return
代表返回给栈帧,逻辑上返回给父 fiber 节点,因此代表父节点。
pendingProps and memoizedProps
记忆(memoization)意味着函数的执行结果,你可以后面再使用,避免重新计算。pendingProps
代表传入组件的 props,memoizedProps 在执行栈的末尾初始化,存储这个节点的 props。(译者注:pendingProps
代表 componentWillReceiveProps 的第一个参数,nextProps, memorized
代表 this.props。这里不知道对不对)
当使用到来的 pendingProps 和 memoizedProps 一样时,代表fiber 之前的输出可以复用,避免没必要的工作量。
fiber 用一个数字代表任务的优先级。ReactPriorityLevel 模块列出了不同的优先级及代表什么。有个例外就是
NoWork 是 0, 标明一个较低的优先级。
例如,你可以使用下面的函数检查 fiber 的优先级是否不大于给定的优先级。调度器(scheduler)使用优先级去搜寻下一个需要执行的工作单元。
function matchesPriority(fiber, priority) {
return fiber.pendingWorkPriority !== 0 &&
fiber.pendingWorkPriority <= priority
} |
Alternate
任何时候,一个组件实例最多两个对应的 fiber 节点:当前的 fiber 和 处于工作中的 fiber。当前
fiber 的alternate 就是 工作中的 fiber,工作中的 alternate 就是当前的
fiber。(当前的 fiber 和 工作中的 fiber 都有一个 alternate 属性指向对方)。当前
fiber 代表已经渲染的内容,工作中的 fiber 是概念上的还没有返回值的栈帧(栈帧在执行中,还没执行完返回值)。
Output
一个 React 应用的叶子节点。对于渲染环境是特定的(比如,在浏览器中是 div, span 等)。在
JSX 中,使用小写的标签名表示。
概念上,一个 fiber 的 output 是一个函数返回值。每个 fiber 最终都有输出(output),但是output
仅仅在叶子节点被宿主组件创建。output 然后转移出树。
output 最终会被送到渲染者,被应用到渲染环境。例如,让我们看下下面的代码的 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')) |
可以看到 fiber 树由单链表的子节点相互连结(单向关系)和父亲指向第一个孩子的关系。这棵树可以使用深度优先算法遍历。
Render 阶段
为了理解 React 如何构建这颗树并且执行调和算法的,我决定用 React 写一个单元测试并且在源码中添加
debugger 追踪执行过程。
如果你对这个过程感兴趣,到这个仓库复制 React 源码。我写的是一个渲染带有文字按钮的简单测试。当你点击按钮时,应用销毁按钮并且渲染一个带有不同文字的
<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 创建了一个用于初始化渲染的 current 树。
createFiverFromTypeAndProps 是使用从特定 React 元素中获取的数据创建每一个
React fiber,当我们运行测试的时候,在函数中放置一个断点,然后看下调用栈,长下面这个样子:
我们可以看到,调用栈追溯到了 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> 节点开始,递归移动到<div>,div
和 button 是 <App> 的孩子。workInprogress 保存了指向下一个拥有要做的任务的
fiber 节点的引用。
performUnitOfWork() 将一个 fiber 节点作为入参,获取到节点的 alternate
属性,然后调用 beginWork()。相当于在执行上下文堆栈中 beginWork 函数的执行上下文中执行。
当 React 构建树的时候,beginWork() 仅仅简单地调用 createFiberFromTypeAndProps()
创建 fiber 节点。React 递归地执行任务,最终 performUnitOfWork() 返回
null,代表已经将这颗树遍历完了。
那么当我们点击按钮执行 instance.handleClick() 简单触发一次状态更新的时候,发生了什么?这种情况下,React
遍历了 fiber 树,克隆每个节点,然后在每个节点上面检查是否有任务要执行。当这个时候看调用堆栈,它可能长下面这个样子:
尽管我们没用在第一次的调用栈中看到 completeUnitOfWork() 和 completeWork(),我们在这里看到了。就像
performUnitOfWork() 和 beginWork(), 这两个函数执行当前执行的一部分的完成,意味着回到栈。
像我们看到的,这四个函数一起担任单元工作执行的任务,然后控制任务能够正确的完成,这正是在栈调试器中缺失的。从下面的图片可以看出,需要完成单元工作每个
fiber 节点有四个阶段组成。
注意每个节点在 它的孩子节点和兄弟节点 返回 completeWork() 之前不用为 <App
> 执行到 completeUnitOfWork() 和 beginWork() , 然后为父节点
只执行到 perfornUnitWork() 和 beginWork(), 一旦<App />
所有的孩子节点完成了就会回到 <App /> 的工作里面。
React 这就完成了 渲染的阶段,基于 click() 更新构建的树被称为 workInProgress
树。这基本上就是等待渲染的草案树(draft tree)了。
Commit 阶段
一旦渲染阶段结束,React 就会来到提交(commit)阶段,在这里交换当前树(current tree)的根指针和
workInProgress 树的指针,从而高效地交换当前树和基于点击事件 click() 更新构建的草稿树(draft
tree)。
不仅如此,React 也会在从 Root 的指针交换为 workInProgress 树之后复用老的
current 树。这种优化过程,从上一个应用的状态到下一个,再到下一个等等,纯粹的是一个平滑的过渡。
那么什么是 16ms 的帧时间呢?React 高效地为每一个执行的任务单元启动一个内部计时器,然后在执行任务的时候持续的模拟期限。超时的时候,React
会暂停当前执行的工作单元,回到主线程,交给你浏览器做一些渲染任务。
然后,在下一帧,React 重回离开的地方,继续构建树。然后,当它拥有足够的时间,它就会提交 workInProgress
树并且完成渲染。
一段关于 fiber 如何工作的 伪代码
const a1 = { name: 'a1', child: null, sibling:
null, return: null };
const b1 = { name: 'b1', child: null, sibling:
null, return: null };
const b2 = { name: 'b2', child: null, sibling:
null, return: null };
const b3 = { name: 'b3', child: null, sibling:
null, return: null };
const c1 = { name: 'c1', child: null, sibling:
null, return: null };
const c2 = { name: 'c2', child: null, sibling:
null, return: null };
const d1 = { name: 'd1', child: null, sibling:
null, return: null };
const d2 = { name: 'd2', child: null, sibling:
null, return: null };
a1.child = b1;
b1.sibling = b2;
b2.sibling = b3;
b2.child = c1;
b3.child = c2;
c1.child = d1;
d1.sibling = d2;
b1.return = b2.return = b3.return = a1;
c1.return = b2;
d1.return = d2.return = c1;
c2.return = b3;
let nextUnitOfWork = a1;
workLoop();
function workLoop() {
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
}
function performUnitOfWork(workInProgress)
{
let next = beginWork(workInProgress);
if (next === null) {
next = completeUnitOfWork(workInProgress);
}
return next;
}
function beginWork(workInProgress) {
log('work performed for ' + workInProgress.name);
return workInProgress.child;
}
function completeUnitOfWork(workInProgress)
{
while (true) {
let returnFiber = workInProgress.return;
let siblingFiber = workInProgress.sibling;
nextUnitOfWork = completeWork(workInProgress);
if (siblingFiber !== null) {
// If there is a sibling, return it
// to perform work for this sibling
return siblingFiber;
} else if (returnFiber !== null) {
// If there's no more work in this returnFiber,
// continue the loop to complete the returnFiber.
workInProgress = returnFiber;
continue;
} else {
// We've reached the root.
return null;
}
}
}
function completeWork(workInProgress) {
log('work completed for ' + workInProgress.name);
return null;
}
function log(message) {
let node = document.createElement('div');
node.textContent = message;
document.body.appendChild(node);
} |
真实代码,每次 workLoop 都判断下 shouldYield()
function workLoop() {
// Perform work until Scheduler asks us to yield
while (workInProgress !== null && !shouldYield())
{
workInProgress = performUnitOfWork(workInProgress);
}
}
// function performUnitOfWork(unitOfWork:
Fiber): Fiber | null {
function performUnitOfWork_React(unitOfWork)
{
// The current, flushed, state of this fiber
is the alternate. Ideally
// nothing should rely on this, but relying
on it here means that we don't
// need an additional field on the work in progress.
const current = unitOfWork.alternate;
startWorkTimer(unitOfWork);
setCurrentDebugFiberInDEV(unitOfWork);
let next;
if (enableProfilerTimer && (unitOfWork.mode
& ProfileMode) !== NoMode) {
startProfilerTimer(unitOfWork);
next = beginWork(current, unitOfWork, renderExpirationTime);
stopProfilerTimerIfRunningAndRecordDelta(unitOfWork,
true);
} else {
next = beginWork(current, unitOfWork, renderExpirationTime);
}
resetCurrentDebugFiberInDEV();
unitOfWork.memoizedProps = unitOfWork.pendingProps;
if (next === null) {
// If this doesn't spawn new work, complete
the current work.
next = completeUnitOfWork(unitOfWork);
}
ReactCurrentOwner.current = null;
return next;
}
// When working on async work, the reconciler
asks the renderer if it should
// yield execution. For DOM, we implement this
with requestIdleCallback.
function shouldYield() {
if (deadlineDidExpire) {
return true;
}
if (
deadline === null ||
deadline.timeRemaining() > timeHeuristicForUnitOfWork
) {
// Disregard deadline.didTimeout. Only expired
work should be flushed
// during a timeout. This path is only hit for
non-expired work.
return false;
}
deadlineDidExpire = true;
return true;
} |
|