本文为意译和整理,若有误导,请放弃阅读。原文html
这篇文章主要是探索React reconciler的新实现-Fiber中的work loop。在文本中,咱们对比和解释了【浏览器的call stack】和【React Fiber架构本身实现的stack】之间的不一样。node
为了自学和回馈社区,我花费了大量的时间去作web技术的逆向工程方面的实践,并写文章记录个人发现。在上一年,我主要是聚焦在Angular的源码上,在网上发表关于Angular方面最大数量的文章-Angular-In-Depth。如今,是时候要深刻到React中来了。Change detection 是我在Angular那边专攻而且有专业水准的领域。但愿在足够的耐心和大量的debugging下面,我可以早日在React这边达到一样的专业水平。react
在React中,change detection机制经常被称为“reconciliation”或者“rendering”。而Fiber就是reconciliation的最新实现。在Fiber架构的底层,它为咱们提供了实现各类有趣特性的能力。好比说:“非阻塞型的渲染(non-blocking rendering)”,“基于priority的更新策略”和“在后台作内容预渲染(pre-rendeing)”。在这些特性在Concurrent React philosophy中被统称为时间切片(time slicing)。git
除了为应用开发者解决实际的问题外,从(软件)工程学的角度来看,这些机制的内部实现一样是有着强大的吸引力。在源码里面,有着大量的知识可以帮助你成为更加优秀的开发者。github
今天,若是你去google“React Fiber”的话,你将会看到大量的关于这方面的文章。它们之中,除了一篇高质量的notes by Andrew Clark ,其余文章的质量嘛......你懂的。本文中,我将会引用这篇笔记里面的某些论述。对Fiber架构中一些挺特别重要的概念,我会给出更加详尽的解释。一旦你看完并理解了这篇文章,你就能很好地看懂来自于ReactConf 2017 Lin Clark的一个很好的演讲。 这是一个你须要好好瞧瞧,好好听听的演讲。可是若是你可以花点时间去研究一下源码,而后回来再看这个演讲,效果更佳。web
这篇文章了开辟了个人【深刻xxx】系列的先河。我相信,我已经大概弄懂了Fiber内部实现细节的70%了。于此同时,我准备要写三篇关于reconciliation和rendering的文章。算法
下面,让咱们开始咱们的探索之旅吧。数组
Fiber架构有两个主要的阶段:浏览器
reconciliation/render阶段和commit阶段。在源码中,大部分地方都会把“reconciliation阶段”称为“render阶段”。就是在render阶段里面,React遍历了组件树,而且作了如下的这些事情:bash
在Fiber架构中,全部的这些activity都被称为“work”。一个fiberr node须要作什么样的work取决于其对应的react element是什么的类型。打比方说,对于一个class component而言,React须要作的work就是实例化这个组件。而对于functional component来讲,它没有这样的work须要去完成。若是你感兴趣的话,这里有你想看的全部的work的类型。这些activity就是Andrew在他的笔记中所说的东西:
When dealing with UIs, the problem is that if too much work is executed all at once, it can cause animations to drop frames…
那上面的“all at once”该如何理解呢?好,基本上,React会以同步的方式去遍历整一颗组件树,对每一个组件进行具体的操做。这样会有什么样的问题呢?实际上,这种方式可能会致使应用逻辑代码的执行时间超过可用的16毫秒。这就会引发界面渲染的掉帧,产生卡顿的视觉效果。
那么,这个问题能被解决吗?
Newer browsers (and React Native) implement APIs that help address this exact problem…
他说的新API就是全局函数requestIdleCallback 。这个全局函数能将一个函数入队起来,而后在浏览器空闲时间里面再去调用它。下面是一个使用的例子:
requestIdleCallback((deadline)=>{
console.log(deadline.timeRemaining(), deadline.didTimeout)
});
复制代码
若是我在Chrome浏览器的控制台上输入以上代码,并执行它。那么,咱们会看到控制台打印出49.9 false
。这个运行结果基本上是在告诉我,你有49.9毫秒的私人时间去作你本身的事情,false
是在告诉我,你没有用完我(指浏览器)分配给你的时间。若是我用完了这个时间,deadline.didTimeout
的值将会是true
。须要时刻提醒本身的是,随着浏览器的运行,timeRemaining
的字段值会改变的。因此,咱们应该常常性地去检查这个字段的值。
requestIdleCallback
实际上的限制比较多和它的执行频率也不够高,致使了没法创造一个流畅的界面渲染体验。因此,React团队不得不实现本身的版本。
如今,假设咱们把React在组件上须要执行的全部的work都放进了performWork
函数里面,而后用requestIdleCallback
去调度这个函数,那么咱们的实现代码应该差很少是这样子的:
requestIdleCallback((deadline) => {
// while we have time, perform work for a part of the components tree
while ((deadline.timeRemaining() > 0 || !deadline.didTimeout) && nextComponent) {
nextComponent = performWork(nextComponent);
}
});
复制代码
咱们一个接一个地在组件上执行work,而后相继地在处理完当前组件以后把下一个组件的引入返回出去,再接着处理。按理说,这种实现应该是可行的。可是这里有一个问题。你不能像reconciliation算法之前的实现那样,采用同步的方式去遍历整一颗组件树。这就是Andrew笔记中所提到的问题:
in order to use those APIs, you need a way to break rendering work into incremental units
所以,为了解决这个问题,React不得不将遍历组件树所用到的算法从新实现一遍。把它从原来的,依赖于浏览器原生call stack的【同步递归模式】改成【用链表和指针实现的异步模式】。Andrew对于这一点,如是说:
若是你仅仅依赖于浏览器原生的call stack的话,那么call stack会一直执行咱们的代码,直到本身被清空为止.....若是咱们能手动地,任意地中断call stack和操做call stack的每个帧岂不是很棒吗?而这就是React Fiber的目的。React Fiber是对stack的从新实现,特别是为了React组件而做的实现。你能够把单独的一个fiber node理解为一个virtual stack frame。
而Andrew说的这些话也正是我打算深刻解释的东西。
我假设大家都熟悉“call stack”这个概念。它是你在浏览器开发工具打断点的时候调试面板所看到的东西。下面是来自于维基百科的引用和图示:
在计算机科学中,call stack是一种存储计算机程序当前正在执行的子程序(subroutine)信息的栈结构......使用call stack的主要缘由是保存一个用于追踪【当前子程序执行完毕后,程序控制权应该归还给谁】的指针.....一个call stack是由多个stack frame组成。每一个stack frame对应于一个子程序调用。做为stack frame,这个子程序此时应该尚未被return语句所终结。举个例子,咱们有一个叫
DrawLine
的子程序正在运行。这个子程序又被另一个叫作DrawSquare
的子程序所调用,那么call stack中顶部的布局应该长成下面那样:
在上面【背景交代】一小节中,咱们提到,React会在reconciliation/render阶段遍历整一颗组件树,而后针对每一组件去执行具体的work。在reconciler的先前的实现中,React使用了【同步递归模式】。这种模式依赖于浏览器原生的call stack。这篇官方文档对这个处理流程进行了阐述,并大量地谈到递归:
By default, when recursing on the children of a DOM node, React just iterates over both lists of children at the same time and generates a mutation whenever there’s a difference.
若是你可以对此进行思考的话,你就会知道,每递归调用一次就是往call stack上增长一个stack frame。这样子的话,整个递归流程表现得是如此的同步(太过同步,某种程度下就表明着阻塞call stack。想深刻call stack,请查阅我整理的:Event Loop究竟是什么鬼?)。假设咱们有如下的一颗组件树:
咱们用一个带有render
方法的object去表明每一个节点。你也能够把这个object当作是组件的实例;
const a1 = {name: 'a1'};
const b1 = {name: 'b1'};
const b2 = {name: 'b2'};
const b3 = {name: 'b3'};
const c1 = {name: 'c1'};
const c2 = {name: 'c2'};
const d1 = {name: 'd1'};
const d2 = {name: 'd2'};
a1.render = () => [b1, b2, b3];
b1.render = () => [];
b2.render = () => [c1];
b3.render = () => [c2];
c1.render = () => [d1, d2];
c2.render = () => [];
d1.render = () => [];
d2.render = () => [];
复制代码
React须要迭代整一颗树,对每个组件执行某些work。为了简单起见,咱们把组件须要执行的work定义为“打印组件的名字,并返回children”。下面一小节就是讲述咱们是如何用递归的方式去实现它的。
负责对组件树迭代的函数叫作walk
。它的具体实现以下:
function walk(instance) {
doWork(instance);
const children = instance.render();
children.forEach(walk);
}
function doWork(o) {
console.log(o.name);
}
walk(a1);
复制代码
执行以上代码,你将会看到如下的输出:
a1, b1, b2, c1, d1, d2, b3, c2
复制代码
若是你以为本身对递归的理解不够深刻的话,欢迎去阅读个人深刻讲解递归的文章。
在这里使用递归是一种很好的直觉,而且也很适合组件树遍历。可是,咱们也发现了它的局限性。其中最大的一点是【咱们不能把work拆分为增量单元(incremental units)】。咱们不能暂停一个特定组件work的执行,而后稍后再恢复执行它。递归模式下,React只能一直迭代下去,直到全部的组件都被处理一遍,call stack清空了才停下来(此谓之“one pass”)。
那么问题就来了。在不使用递归模式的状况下,React是如何实现遍历组件树的算法呢?答案是,它使用了单链表(singly-linked-list)式的树遍历算法。这使得遍历暂停和防止stack高度增加成为了可能(stop the stack from growing)。
我庆幸本身在这里找到Sebastian Markbåge总结的算法概述。为了实现这个算法,咱们须要由三个字段连接起来的数据结构:
在React新的reconciliation算法这个语境下,具有这三个字段的数据结构被称为“Fiber node”。在底层,它表明着具备work须要执行的react element。想看更多展开的阐述,请看我下一篇文章。
下面这个图演示了linked-list中节点的层级和它们之间存在的关系:
下面,让咱们一块儿来定义一下咱们本身的fiber node的构造函数吧:
class Node {
constructor(instance) {
this.instance = instance;
this.child = null;
this.sibling = null;
this.return = null;
}
}
复制代码
下面再实现一个将从组件实例的render方法返回的children连接在一块,使它们成为linked-list的函数。这个函数接收一个【parent fiber node】和【由组件实例组成的数组】做为输入,最后返回parent fiber node的第一个child的引用:
function link(parent, elements) {
if (elements === null) elements = [];
parent.child = elements.reduceRight((previous, current) => {
const node = new Node(current);
node.return = parent;
node.sibling = previous;
return node;
}, null);
return parent.child;
}
复制代码
这个函数从倒数第一个开始(注意看,这里是用了reduceRight方法),遍历数组里面的每个元素,把它们连接成一个linked-list。最后,把parent fiber node的第一个child fiber node的引用返回出去。下面这个代码演示一下这个函数的使用:
const children = [{name: 'b1'}, {name: 'b2'}];
const parent = new Node({name: 'a1'});
const child = link(parent, children);
// the following two statements are true
console.log(child.instance.name === 'b1');
console.log(child.sibling.instance === children[1]);
复制代码
同时,咱们也须要实现一个helper函数来帮助咱们在fiber node身上执行具体的work。在本示例中,这个work就是简单地打印出组件实例的名字。这个helper函数除了执行work以外,还获取到了组件最新的children list,而后将他们连接到一块了:
function doWork(node) {
console.log(node.instance.name);
const children = node.instance.render();
return link(node, children);
}
复制代码
okay,万事俱备只欠东风。下面,咱们去实现具体的遍历算法。这是一个深度优先的算法实现。下面是加上了点注释的实现代码:
// 参数o你能够说它是一个fiber node也能够说它是一颗fiber node tree
function walk(o) {
let root = o;
let current = o;
while (true) {
// perform work for a node, retrieve & link the children
let child = doWork(current);
// if there is a child, set it as the current active node
if (child) {
current = child;
continue;
}
// if we have returned to the top, exit the function
if (current === root) {
return;
}
// keep going up until we find the sibling
while (!current.sibling) {
// if we have returned to the top, exit the function
if (!current.return || current.return === root) {
return;
}
// set the parent as the current active node
current = current.return;
}
// if found, set the sibling as the current active node
current = current.sibling;
}
}
复制代码
尽管上面的实现代码不是特别难以理解。可是,我以为你最好好好把玩一下它,这样你才能理解得更透彻。Do it here。这个实现的中心思想是:保留一个指向当前被处理fiber node的引用,随着深度优先的向下遍历,不断地修正这个引用,直到遍历触及到这个树分支的叶子节点。一旦到底了,咱们就经过return
字段,层层地返回到上一层的parent fiber node上去。
若是此时咱们去看看这个实现的call stack的话,那么咱们将会看到这样的画面:
正如你所看到的那样,随着咱们的遍历,这个stack的高度并无增长。可是若是咱们在doWork函数里面打个断点的话,并把组件实例节点的名字打印出来的话,咱们将会看到这样的结果:
这个结果的动画跟浏览器call stack的表现很像(不一样点在于,call stack的栈底是在下面,而这里是在上面)。有了这个算法实现,咱们就可以很好地把浏览器的call stack替换为咱们本身的stack。这就是Andrew在他的笔记中所讲到的一点:
Fiber is re-implementation of the stack, specialized for React components. You can think of a single fiber as a virtual stack frame.
如今咱们可以保存一个fiber node的引用(这个fiber node充当着stack的top frame),并经过不断地切换它的指向
某种状况下,指向它的child fiber node,某种状况下指向它的sibling fiber node,某种状况下指向它的return/parent fiber node
来控制咱们的“call stack”了:
function walk(o) {
let root = o;
let current = o;
while (true) {
...
current = child;
...
current = current.return;
...
current = current.sibling;
}
}
复制代码
所以,咱们可以在遍历过程当中随意地暂停和恢复执行。而这也是可以使用新的requestIdleCallback
API的先决条件。
下面是React中实现work loop的代码:
function workLoop(isYieldy) {
if (!isYieldy) {
// Flush work without yielding
while (nextUnitOfWork !== null) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
} else {
// Flush asynchronous work until the deadline runs out of time.
while (nextUnitOfWork !== null && !shouldYield()) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
}
}
复制代码
正如你所看到的那样,React中的实现跟咱们上面提到的算法实现十分类似。它也是经过nextUnitOfWork
变量来保存一个【表明top frame的】fiber node引用。
React实现的walk loop算法能以同步的方式去遍历组件树,并在每一个fiber node上(nextUnitOfWork)执行某些 work。同步的方式每每是发生在所谓的【因为UI事件(好比,click,input等)的发生而致使的】“交互式更新”场景下。除了同步方式,walk loop也可以以异步的方式去进行。在遍历过程当中,在每执行一个fiber node相关work以前,该算法会去检查当前是否还有可用时间。shouldYield
函数会基于deadlineDidExpire和deadline的变量值去返回结果。这两个变量的值会随着React对fiber node的work执行的推动而随时被更新的。
想要了解peformUnitOfWork
的更多细节,请查阅这篇文章:深刻React Fiber架构的reconciliation 算法。