在React的早期版本,React渲染组件的时候必须递归的处理虚拟DOM树,这就意味着要等到整颗树都处理完毕后才会把控制权返回给浏览器,这就导致长时间的阻塞页面渲染,导致页面掉帧,Fiber就是为了解决这个问题出现的, 所以Fiber就是为了将大计算量的任务分片处理出现的,但同时Fiber也是React通向并发渲染的地基,分片是最能被看见的收益
Fiber为React带来的新活力
Fiber为React引入了在处理大任务的更多强大特性。在Fiber架构下,计算虚拟DOM和将虚拟DOM映射入真实DOM两步操作分为render阶段和commit阶段。
优先级调度
在render阶段React允许你中断任务,并且这个时候如果有高优先级别的任务要处理,React会调度暂停当前任务,立即执行高优先级别的任务,这就是React提供的优先级调度能力。
中断
其中低优先级的任务被暂停的,这就是React提供的可中断能力。
可恢复
对于被高优先级的任务,React是直接丢弃,对于时间切片的任务,使用nextUnitOfWork保存执行节点,在下一次执行恢复执行
双缓冲
在React的计算中,它不是去修改已有的虚拟dom树,而是从需要更新的组件开始(严格讲其实是从根开始,只是React通过优化手段把上面剪掉了,所以实际上是可以从订阅了状态的组件开始),重新计算一套虚拟DOM, 我们就得到了两套虚拟DOM, 一个是旧虚拟DOM, 这个虚拟DOM被映射在浏览器的界面上,可以说是current树,而我们在render阶段创建的新的虚拟dom树,可以说是work-in-progress树,这是React可随意中断恢复的基础。
| 特性 | 说明 |
|---|---|
| 可中断 | render阶段的工作可以随时暂停(主动暂停或被高优先级任务被动调度中断),浏览器主线程不会被长时间占用 |
| 可恢复 | 暂停后可以在下一帧从断点继续,不丢失进度 |
| 优先级调度 | 紧急更新可以打断低优先级更新 |
| 双缓冲 | 内存中维护两颗树,current树和work-in-progress树,commit节点切换两颗树 |
代码演示
render阶段
下面是一个完整的演示例子用于展示React的Fiber架构, 首先我们创建一个虚拟DOM
js
const App = createElement("div", null,
createElement("h1", null, "Hello Fiber"),
createElement("p", { onClick: () => alert("clicked") }, "Click me"));
其中的createElement是一个用于创建虚拟DOM的函数,作用和用法其实相当于vue的h函数。
javascript
function createElement(type, props, ...children) {
return {
type,
props: {
...props,
children: children.flat().map((item) => typeof item === "object" ? item : createTextElement(item))
}
}
}
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text
}
}
}
最后我们得到的虚拟dom是这样的
json
{
"type": "div",
"props": {},
"children": [
{
"type": "h1",
"props": {},
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Hello Fiber"
}
}
]
},
{
"type": "p",
"props": {},
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Click me"
}
}
]
}
]
}
我们现在进行任务调度,生成Fiber根节点,Fiber节点是虚拟DOM的增强版本,用于树的遍历
yaml
let wipRoot = null;
let nextUnitOfWork = null;
function render(vnode, container) {
wipRoot = {
type: "Root",
dom: container,
props: {
children: [vnode]
},
parent: null,
child: null,
sibling: null,
effectTag: null
};
nextUnitOfWork = wipRoot;
}
写一个workLoop ,流程是先处理render阶段,然后每帧的空闲时间处理一个Fiber节点,然后在render完成后开启commit阶段
scss
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 1) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
//处理一个Fiber节点
}
if (!nextUnitOfWork && wipRoot) {
commitRoot(); //开启commit阶段
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
在我们的render阶段,核心就是performUnitOfWork, 这个函数的目标就是创建真实dom, 然后把子组件转成一个Fiber链表, 逐个处理
lua
function performUnitOfWork (fiber) {
if (!fiber.dom && fiber.type !== "Root") {
fiber.dom = createDom(fiber);
}
}
这里的createDom用于创建真实DOM, 根据我们上述的虚拟节点dom的数据结构,可以很轻松的创建出有相应的属性和事件处理函数的dom节点
ini
function createDom(fiber) {
const dom = fiber.type === "TEXT_ELEMENT" ?
document.createTextNode(fiber.props.nodeValue) :
document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);//附加上对应的属性和事件监听器
return dom;
}
const isEvent = (key) => key.startsWith("on");
const isProp = (key) => !isEvent(key) && key !== "children";
const getEventName = (key) => key.split("on")[1].toLowerCase();
function updateDom(dom, source, target) {
const sourceKeys = Object.keys(source);
const targetKeys = Object.keys(target);
const { event: sourceEvent = [], prop: sourceProp = [] } = Object.groupBy(sourceKeys, (key) => {
return isEvent(key) ? "event" : isProp(key) ? "prop" : "none";
});
const { event: targetEvent = [], prop: targetProp = [] } = Object.groupBy(targetKeys, (key) => {
return isEvent(key) ? "event" : isProp(key) ? "prop" : "none";
});
sourceEvent.forEach((originName) => {
const eventName = getEventName(originName);
dom.removeEventListener(eventName, source[originName]);
});
sourceProp.forEach((key) => {
dom[key] = "";
});
targetEvent.forEach((originName) => {
const eventName = getEventName(originName);
dom.addEventListener(eventName, target[originName]);
});
targetProp.forEach((key) => {
dom[key] = target[key];
});
}
结束创建DOM的工作,回到我们的performUnitOfWork,,接下来我们需要把Fiber节点的子虚拟DOM转成一个工作流链表, 方便中断和恢复,树的结构是可以做的,但是没有链表合适,
ini
function performUnitOfWork (fiber) {
...
reconcileChildren(fiber, fiber.props.children);
...
}
function reconcileChildren(fiber, childs) {
if (!(childs && Array.isArray(childs))) return null;
let prev;
childs.forEach((child, index) => {
//创建一个Fiber节点
const newFiber = {
type: child.type,
props: child.props,
dom: null,
parent: fiber,
sibling: null,
child: null,
effectTag: "PLACEMENT"
};
if (index === 0) {
fiber.child = newFiber;
} else {
prev.sibling = newFiber;
}
prev = newFiber;
});
}
把当前节点的链表组件完毕后,继续组装下一个Fiber节点, 回到performUnitOfWork
ini
function performUnitOfWork(fiber) {
....
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.parent;
}
}
这里performUnitOfWork 最后返回下一个要处理的Fiber节点,由workLoop在下一次空闲的时候继续帮我们处理下一个Fiber节点。正是因为遍历被改写成了"沿 child → sibling → parent 指针走的循环",进度才能用 nextUnitOfWork 一个变量记住,从而做到随时中断、随时恢复:

直到最后全部组装完毕,我们查看一下我们在render阶段得到的最终Fiber树是什么样的
kotlin
JSON.stringify(wipRoot, (key, val) => {
if (key === "dom" || key === "parent") return ;
return val;
});
Fiber树的最终结果如下
json
{
"type": "Root",
"props": {
"children": [
{
"type": "div",
"props": {
"children": [
{
"type": "h1",
"props": {
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Hello Fiber"
}
}
]
}
},
{
"type": "p",
"props": {
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Click me"
}
}
]
}
}
]
}
}
]
},
"sibling": null,
"child": {
"type": "div",
"props": {
"children": [
{
"type": "h1",
"props": {
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Hello Fiber"
}
}
]
}
},
{
"type": "p",
"props": {
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Click me"
}
}
]
}
}
]
},
"sibling": null,
"child": {
"type": "h1",
"props": {
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Hello Fiber"
}
}
]
},
"sibling": {
"type": "p",
"props": {
"children": [
{
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Click me"
}
}
]
},
"sibling": null,
"child": {
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Click me"
},
"sibling": null,
"child": null,
"effectTag": "PLACEMENT"
},
"effectTag": "PLACEMENT"
},
"child": {
"type": "TEXT_ELEMENT",
"props": {
"nodeValue": "Hello Fiber"
},
"sibling": null,
"child": null,
"effectTag": "PLACEMENT"
},
"effectTag": "PLACEMENT"
},
"effectTag": "PLACEMENT"
},
"effectTag": null
}
可以看到props里的children用于存储虚拟dom, 而child和sibling,parent(因为会导致循环引用,所以这里过滤掉了,没有展示出来)是存储Fiber节点,在children可以看到有很多重复对象,实际上是共用同一套引用对象,effectTag是一个二进制掩码,可以组合表示多种要处理的副作用,我们这里简化了,就处理单一副作用,用的是字符串枚举, "PLACEMENT", 也没有Deletion,
commit阶段
这里进入commit阶段,在commit阶段,主要是映射虚拟dom到真实dom
ini
function commitRoot() {
commitWork(wipRoot.child);
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
if (fiber.effectTag === "PLACEMENT" && fiber.dom) {
let parent = fiber.parent;
while (parent && !parent.dom) {
parent = parent.parent;
}
parent.dom.appendChild(fiber.dom);
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
可以看到commit是一个同步递归的任务,而且是没有办法被打断的,
完整代码及演示结果查看如下
增强版本
上述代码只是演示了一个最小Fiber架构代码,并没有展现出我们Fiber架构中优先级调度中断恢复双缓冲等功能,下面我们逐步引入这几大代码。
双缓冲
在React中的Fiber架构中,每个节点通过alternate字段指向另一颗树的对应节点,这样在render阶段,我们操作wip(work in progress)树,不影响到屏幕上的current树, 在commit阶段新旧虚拟DOM树交换的时候, 也不需要遍历旧虚拟dom树进行节点更新, 只需要交换新旧虚拟dom树就可以了,在关键的diff对比节点,可以通过新旧节点的对比,判断复用/更新/删除

javascript
let currentRoot = null;
function render (element, dom) {
wipRoot = {
dom,
props: {...},
...
alternate: currentRoot
}
}
let deletions = [];
function reconcileChildren(wipFiber, elements) {
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let preFiber = null;
for (let i = 0, len = elements.length; i < len || oldFiber; i++) {
const element = elements[i];
const sameType = oldFiber && element && oldFiber.type === element.type;
let newFiber;
if (sameType) {
newFiber = {
type: element.type,
props: element.props,
dom: oldFiber.dom,
parent: wipFiber,
child: null,
sibling: null,
alternate: oldFiber,
effectTag: "UPDATE"
}
}
if (!sameType && element) {
newFiber = {
type: element.type,
props: element.props,
dom: null,
parent: wipFiber,
child: null,
sibling: null,
alternate: oldFiber,
effectTag: "PLACEMENT"
}
}
if (!sameType && oldFiber) {
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) oldFiber = oldFiber.sibling;
if (newFiber) {
if (i == 0) wipFiber.child = newFiber
else preFiber.sibling = newFiber
preFiber = newFiber;
}
}
}
然后在commit阶段我们只需要交换currentRoot和wipRoot两颗树,然后把currentRoot树重新映射到真实dom树中即可
ini
function commitRoot () {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
deletions = [];
}
function commitWork (fiber) {
if (!fiber) return ;
let domParentFiber = fiber.parent;
while (domParentFiber && !domParentFiber.dom) domParentFiber = domParentFiber.parent;
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null)
domParent.appendChild(fiber.dom);
else if(fiber.effectTag === "UPDATE" && fiber.dom != null)
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
else if (fiber.effectTag === "DELETION") {
fiber.dom.remove();//这里简化只处理当前节点,不处理后代
return false;
}
commitWork(fiber.child);
commitWork(fiber.sibling)
}
在上述代码中,可以看到Fiber提供的current树和wip树,在render阶段我们进行wip构建,在commit阶段则交换current树和wip,一次性的同步映射到真实dom上,这也就避免了React在current树做diff和修改,导致组件可能只完成一半(尤其是是高优先级任务插队的时候,低优先级的渲染可能就执行一半就中断了),页面呈现新旧UI混合的半成品状态,即"渲染撕裂";双缓冲树的体系下,React先展示旧内容,然后默默构建wip树,完成后一次性映射到DOM上,就没有渲染一半的说法了,就是一次完整的渲染
优先级调度
React的调度,是为了快速响应用户交互而实现的,防止长任务执行过程中页面点击事件被阻塞, 用户感觉页面"卡死了",下面展示一个最小调度逻辑
ini
function scheduleWork(priority) {
if (priority < wipPriority) {
wipRoot = {
type: "Root",
dom: currentRoot.dom,
props: currentRoot.props,
alternate: currentRoot
};
wipPriority = priority;
deletions = [];
nextUnitOfWork = wipRoot;
}
}
在这个代码中,要是有高优先级别的任务调用了scheduleWork, 则我们全量从根节点开始构建,React则在背后使用其它手段使得避免全量构建,
在这里直接丢弃了之前的nextUnitOfWork
中断/恢复
中断/恢复功能其实在基础版本就体现出来了,通过requestIdleCallback将任务分片,每次都断在nextUnitOfWork,然后中断,下一次又在nextUnitOfWork这个Fiber节点上恢复任务,这是不丢弃的,在调度部分,则是直接把之前的工作丢弃了
为什么Vue不需要Fiber ?
vue得益于它的响应式系统, 所有的状态改变可以精准溯源到对应的组件,在组件级别做更新就可以了,所以任务被拆分的小,没有一次性要构建整个虚拟DOM的需求,所以就没有使用Fiber机制进行任务切片的需求,相比之下, React的响应式机制是没有办法做到vue这样的细粒度的,只能去执行一遍组件函数,然后全量的构建看看哪些地方是变化的,通过各种优化手段把没变的分支剪掉, 避免全量构建虚拟dom。