为什么React不采用数组保存副作用节点,而是采用逐级提交副作用链表的方式,从树中构建副作用链表?本章介绍fiber副作用及其构建副作用链表的过程。欢迎关注mini-react一起学习react源码
知识点
- 了解什么是 fiber 副作用,以及 fiber 中与副作用相关的属性
- 了解如何构建副作用链表
深入理解 React Fiber 副作用链表的构建算法
React 在 render 阶段构建副作用链表。其中,在 reconcile children(协调子节点) 时,如果旧的子节点需要删除,则标记为 Deletion 副作用,并添加到父节点的副作用链表中,这个操作在 beginWork 阶段完成。其余类型的副作用节点都在 completeUnitOfWork 阶段添加到父节点的副作用链表中。
假设我们在更新时需要渲染以下新的节点,A、B、D 都是需要更新的,而 C 是需要删除的
// 旧的节点
<div id="A">
<div id="B"></div>
<div id="C"></div>
<div id="D"></div>
</div>
// 更新后新的节点
<div id="A-1">
<div id="B-1"></div>
<div id="D-1"></div>
</div>
我们按照 React 渲染流程(如果对渲染流程不熟悉,可以查看这篇文章)来拆解这个过程
// React渲染流程主要源码
function performUnitOfWork(unitOfWork) {
// beginWork主要逻辑就是协调子节点,即根据最新的react element元素和旧的fiber节点进行对比
const next = beginWork(current, unitOfWork, subtreeRenderLanes); // next 就是当前节点unitOfWork的子节点
if (next === null) {
// 如果没有子节点,说明当前节点可以完成了
completeUnitOfWork(unitOfWork);
} else {
workInProgress = next;
}
}
对于新的节点"A-1",我们调用 performUnitOfWork(div#A-1) 开始工作:
- 执行 beginWork,比较新的子节点("B-1","D-1") 以及 旧的 fiber 节点("B","C","D"),发现 "C" 需要被删除,因此将 "C" 添加到父节点,即"A-1"的副作用链表中。"B" 以及 "D" 需要更新。beginWork 执行完成,将新的子节点 "B-1" 返回
- "A-1" 还不可以完成,因为有子节点 "B-1" 返回:
- 对于节点 "B-1",执行 beginWork,由于"B-1"没有子节点,因此 next 为 null,调用 completeUnitOfWork 完成 "B-1" 节点。在 completeUnitOfWork 中判断"B-1"有副作用,需要更新,因此将其添加到父节点"A-1"的副作用链表中。同时返回兄弟节点"D-1"继续工作
- 对于节点 "D-1",执行 beginWork,由于"D-1"没有子节点,因此 next 为 null,调用 completeUnitOfWork 完成 "D-1" 节点。在 completeUnitOfWork 中判断"D-1"有副作用,需要更新,因此将其添加到父节点"A-1"的副作用链表中。由于"D-1"没有子节点,因此父节点"A-1"可以完成了
- 调用 completeUnitOfWork 完成节点"A-1"
因此,对于一个节点来说,它的副作用链表,被删除的子节点都在链表前面(至少在 React18 以前是这样)。删除的副作用是最先加到父节点的副作用链表中的,其次才是其他类型的副作用节点。 因为在 render 阶段,React 首先调用 beginWork 协调当前节点(比如 A)的子节点
从以上过程也可以看出,React 是自底向上构建副作用链表的
在开始下面的内容之前,可以在 react-dom.development.js 中找到 performSyncWorkOnRoot 方法,在调用 commitRoot(root) 方法前添加一行代码 printEffectList(finishedWork)。用于在将副作用链表打印出来,方便我们直观感受副作用链表的遍历顺序。
function printEffectList(finishedWork) {
let nextEffect = finishedWork.firstEffect;
while (nextEffect) {
const id = nextEffect.memoizedProps.id;
const label = nextEffect.type + "#" + id;
let flagOperate = "";
if ((nextEffect.flags & Placement) !== NoFlags) {
flagOperate += "插入";
}
if ((nextEffect.flags & Update) !== NoFlags) {
flagOperate += "更新";
}
if ((nextEffect.flags & Deletion) !== NoFlags) {
flagOperate += "删除";
}
if ((nextEffect.flags & ContentReset) !== NoFlags) {
flagOperate += "重置文本内容";
}
if ((nextEffect.flags & Callback) !== NoFlags) {
flagOperate += "回调";
}
console.log(flagOperate + label);
nextEffect = nextEffect.nextEffect;
}
}
fiber 副作用
React 通过 fiber flag 属性标记副作用。如果不明白副作用是啥,可以看这篇文章深入理解 React Fiber 副作用。
fiber 中与副作用相关的属性如下:
function FiberNode() {
// ...
// Effects
this.flags = NoFlags;
this.nextEffect = null;
this.firstEffect = null;
this.lastEffect = null;
// ...
}
同时,建议使用以下 demo 测试:
import React from "react";
import ReactDOM from "react-dom";
class Home extends React.Component {
constructor(props) {
super(props);
this.state = { step: 0 };
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
this.setState({
step: this.state.step + 1,
});
}
render() {
const { step } = this.state;
return (
<div
style={{ height: "100px" }}
id={"A-" + step}
onClick={this.handleClick}
>
<div id={"B" + step}></div>
<div id={"C" + step}></div>
</div>
);
}
}
ReactDOM.render(<Home />, document.getElementById("root"));
fiber 节点副作用链表
每个 fiber 节点都各自维护一个单向的具有副作用的子节点链表,其中 firstEffect 指向表头。lastEffect 指向表尾。子节点之间通过 nextEffect 连接。在 completeUnitOfWork 阶段,fiber 节点向父节点上交自己的副作用链表,这么说有点抽象,下面我们通过几个例子实践一下
React 在页面第一次渲染时,不会追踪副作用,因此 React 在第一次页面渲染时,是不会构建副作用链表的。所以在我们的例子中,不考虑页面第一次渲染的情况,我们只关注点击按钮触发页面更新的阶段
只有父子节点更新的情况
render() {
const { step } = this.state;
return (
<div
style={{ height: "100px" }}
id={"A" + step}
onClick={this.handleClick}
>
<div id={"B" + step}></div>
<div id={"C" + step}></div>
</div>
);
}
当我们点击页面时,控制台依次打印:
更新div#B1
更新div#C1
更新div#A1
可以看出,div#B1 节点先完成,其次是 div#C1,最后才是父节点 div#A1,这三个节点都是具有更新的副作用,对应的副作用链表如下:
我们可以简单的实现下这个算法:
const Update = 4;
const Placement = 2;
const Deletion = 8;
const NoFlags = 0;
const HostRootFiber = { id: "root", flags: 0 };
function printEffectList(finishedWork) {
let nextEffect = finishedWork.firstEffect;
while (nextEffect) {
const id = nextEffect.id;
const label = "div#" + id;
let flagOperate = "";
if ((nextEffect.flags & Placement) !== NoFlags) {
flagOperate += "插入";
}
if ((nextEffect.flags & Update) !== NoFlags) {
flagOperate += "更新";
}
if ((nextEffect.flags & Deletion) !== NoFlags) {
flagOperate += "删除";
}
console.log(flagOperate + label);
nextEffect = nextEffect.nextEffect;
}
}
const fiberA = { id: "A1", flags: Update, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
function completeUnitOfWork(unitOfWork) {
const returnFiber = unitOfWork.return;
if (!returnFiber) return;
const flags = unitOfWork.flags;
// flags > 1才说明该节点具有副作用,才可以提交到其父节点中
if (flags > 1) {
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = unitOfWork;
} else {
returnFiber.firstEffect = unitOfWork;
}
returnFiber.lastEffect = unitOfWork;
}
}
completeUnitOfWork(fiberB);
completeUnitOfWork(fiberC);
completeUnitOfWork(fiberA);
printEffectList(fiberA);
复制这段代码,可以在本地测试一下,会发现控制台只打印了:
更新div#B1
更新div#C1
没有打印 更新div#A1,这是因为在实际的场景中,"div#A1"也是需要向它的父 fiber 节点提交它的整个副作用链表的,同时将自身添加到它的副作用链表末尾。这里我们直接假设 "div#A1" 的父节点就是我们的容器节点"div#root"。整个提交过程如下图所示:
我们来完善一下我们的 completeUnitOfWork 以支持向父节点提交当前节点的副作用链表:
function completeUnitOfWork(unitOfWork) {
const returnFiber = unitOfWork.return;
if (!returnFiber) return;
const flags = unitOfWork.flags;
// flags > 1才说明该节点具有副作用,才可以提交到其父节点中
if (flags > 1) {
// 第一步 让父节点的firstEffect指向当前节点的firstEffect
// 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = unitOfWork.firstEffect;
}
// 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
// 如果存在lastEffect,说明当前节点存在副作用链表
if (unitOfWork.lastEffect) {
returnFiber.lastEffect = unitOfWork.lastEffect;
}
if (returnFiber.lastEffect) {
// 第三步,将当前节点添加到其副作用链表末尾
returnFiber.lastEffect.nextEffect = unitOfWork;
} else {
returnFiber.firstEffect = unitOfWork;
}
returnFiber.lastEffect = unitOfWork;
}
}
执行代码,观察控制台输出,可以发现符合我们的预期。
这里又有一个问题,假如 fiberA 没有副作用,即 flags 为 0:
const fiberA = { id: "A1", flags: 0, return: HostRootFiber };
这时候执行代码,发现控制台打印为空。这是为什么?原因很简单,这里我们需要注意,即使当前 fiber 节点没有副作用,但是它有副作用链表,比如 fiberA,没有副作用,但是它子节点有副作用,也就是 fiberA 还是存在副作用链表的,即 fiberA 的 firstEffect 以及 lastEffect 都不为空,因此我们也是需要将 fiberA 的副作用链表提交到 fiberA 的父节点中的。
在我们的 completeUnitOfWork 中,前两步都是在向父节点提交副作用链表,我们可以将这个逻辑挪出判断当前 fiber 节点是否有副作用外面去:
const fiberA = { id: "A1", flags: 0, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
function completeUnitOfWork(unitOfWork) {
const returnFiber = unitOfWork.return;
if (!returnFiber) return;
const flags = unitOfWork.flags;
// 首先,不管当前unitOfWork节点是否有副作用,都需要将它的副作用链表提交到父节点中
// 第一步 让父节点的firstEffect指向当前节点的firstEffect
// 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = unitOfWork.firstEffect;
}
// 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
// 如果存在lastEffect,说明当前节点存在副作用链表
if (unitOfWork.lastEffect) {
returnFiber.lastEffect = unitOfWork.lastEffect;
}
// 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
// flags > 1才说明该节点具有副作用,才可以提交到其父节点中
if (flags > 1) {
if (returnFiber.lastEffect) {
// 第三步,将当前节点添加到其副作用链表末尾
returnFiber.lastEffect.nextEffect = unitOfWork;
} else {
returnFiber.firstEffect = unitOfWork;
}
returnFiber.lastEffect = unitOfWork;
}
}
复杂节点更新的情况
render() {
const { step } = this.state;
return [
<div
style={{ height: "100px" }}
id={"A" + step}
onClick={this.handleClick}
>
<div id={"B" + step}></div>
<div id={"C" + step}></div>
</div>,
<div id={"E" + step}>
<div id={"F" + step}></div>
<div id={"G" + step}></div>
</div>,
];
}
根据 React 渲染流程我们可以知道,节点完成顺序如下:B,C,A,F,G,E
当我们点击页面时,控制台依次打印:
更新div#B1
更新div#C1
更新div#A1
更新div#F1
更新div#G1
更新div#E1
运行我们上一节实现的 completeUnitOfWork:
const fiberA = { id: "A1", flags: Update, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
const fiberE = { id: "E1", flags: Update, return: HostRootFiber };
const fiberF = { id: "F1", flags: Update, return: fiberE };
const fiberG = { id: "G1", flags: Update, return: fiberE };
function completeUnitOfWork(unitOfWork) {
const returnFiber = unitOfWork.return;
if (!returnFiber) return;
const flags = unitOfWork.flags;
// 第一步 让父节点的firstEffect指向当前节点的firstEffect
// 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = unitOfWork.firstEffect;
}
// 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
// 如果存在lastEffect,说明当前节点存在副作用链表
if (unitOfWork.lastEffect) {
returnFiber.lastEffect = unitOfWork.lastEffect;
}
// 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
// flags > 1才说明该节点具有副作用,才可以提交到其父节点中
if (flags > 1) {
if (returnFiber.lastEffect) {
// 第三步,将当前节点添加到其副作用链表末尾
returnFiber.lastEffect.nextEffect = unitOfWork;
} else {
returnFiber.firstEffect = unitOfWork;
}
returnFiber.lastEffect = unitOfWork;
}
}
completeUnitOfWork(fiberB);
completeUnitOfWork(fiberC);
completeUnitOfWork(fiberA);
completeUnitOfWork(fiberF);
completeUnitOfWork(fiberG);
completeUnitOfWork(fiberE);
printEffectList(HostRootFiber);
运行完成可以发现控制台只打印了 B1、C1、A1。问题就出在了 completeUnitOfWork(fiberE); E 节点的完成时。
根据上图,我们修改一下我们的代码,在向父节点提交自己的副作用链表时,判断一下父节点是否已经存在了副作用链表,如果父节点已经存在副作用链表,则将自己的副作用链表追加到父节点的副作用链表后面:
function completeUnitOfWork(unitOfWork) {
const returnFiber = unitOfWork.return;
if (!returnFiber) return;
const flags = unitOfWork.flags;
// 第一步 让父节点的firstEffect指向当前节点的firstEffect
// 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = unitOfWork.firstEffect;
}
// 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
// 如果存在lastEffect,说明当前节点存在副作用链表
if (unitOfWork.lastEffect) {
// 在向父节点提交自己的副作用链表时,需要判断父节点是否已经存在副作用链表。如果父节点已经有副作用链表,那么将自己的表头
// 追加到父节点的副作用链表中
// return.lastEffect存在,说明父节点已经存在副作用链表
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = unitOfWork.firstEffect;
}
returnFiber.lastEffect = unitOfWork.lastEffect;
}
// 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
// flags > 1才说明该节点具有副作用,才可以提交到其父节点中
if (flags > 1) {
if (returnFiber.lastEffect) {
// 第三步,将当前节点添加到其副作用链表末尾
returnFiber.lastEffect.nextEffect = unitOfWork;
} else {
returnFiber.firstEffect = unitOfWork;
}
returnFiber.lastEffect = unitOfWork;
}
}
控制台执行,发现输出已经符合我们的预期了。
以上就是最终 React 在 completeUnitOfWork 函数中构建副作用链表的逻辑,这里我们省略了 completeUnitOfWork 函数中的 while 循环,改成手动为每个 fiber 节点调用 completeUnitOfWork,但丝毫不影响我们理解构建副作用链表的过程
既更新又删除节点的复杂情况
render() {
const { step } = this.state;
return [
<div
style={{ height: "100px" }}
id={"A" + step}
onClick={this.handleClick}
>
<div id={"B" + step}></div>
<div id={"C" + step}></div>
{!(step % 2) && <div id={"D" + step}></div>}
</div>,
<div id={"E" + step}>
<div id={"F" + step}></div>
{!(step % 2) && <div id={"H" + step}></div>}
<div id={"G" + step}></div>
{!(step % 2) && <div id={"I" + step}></div>}
</div>,
];
}
当我们点击页面时,触发更新时,根据 React 渲染流程我们可以知道,在 render 阶段,为 A1 节点协调子节点时,D0 被标记为具有删除的副作用,并且首先添加到父节点 A1 的副作用链表中。其次完成 B1、C1、A1。
同样,在 render 阶段,在为 E1 节点协调子节点时,H0 首先被标记为具有删除的副作用,并且首先添加到父节点 E1 的副作用链表中,其次 I0 被标记为具有删除的副作用,并且添加到父节点 E1 的副作用链表中,最后依次完成 F1、G1、E1
控制台依次打印:
删除div#D0
更新div#B1
更新div#C1
更新div#A1
删除div#H0
删除div#I0
更新div#F1
更新div#G1
更新div#E1
我们新增一个 deleteChild 方法,实现节点删除的情况:
const Update = 4;
const Placement = 2;
const Deletion = 8;
const NoFlags = 0;
const HostRootFiber = { id: "root", flags: 0 };
function printEffectList(finishedWork) {
let nextEffect = finishedWork.firstEffect;
while (nextEffect) {
const id = nextEffect.id;
const label = "div#" + id;
let flagOperate = "";
if ((nextEffect.flags & Placement) !== NoFlags) {
flagOperate += "插入";
}
if ((nextEffect.flags & Update) !== NoFlags) {
flagOperate += "更新";
}
if ((nextEffect.flags & Deletion) !== NoFlags) {
flagOperate += "删除";
}
console.log(flagOperate + label);
nextEffect = nextEffect.nextEffect;
}
}
const fiberA = { id: "A1", flags: Update, return: HostRootFiber };
const fiberB = { id: "B1", flags: Update, return: fiberA };
const fiberC = { id: "C1", flags: Update, return: fiberA };
const fiberD = { id: "D0", flags: Deletion, return: fiberA };
const fiberE = { id: "E1", flags: Update, return: HostRootFiber };
const fiberF = { id: "F1", flags: Update, return: fiberE };
const fiberG = { id: "G1", flags: Update, return: fiberE };
const fiberH = { id: "H0", flags: Deletion, return: fiberE };
const fiberI = { id: "I0", flags: Deletion, return: fiberE };
function completeUnitOfWork(unitOfWork) {
const returnFiber = unitOfWork.return;
if (!returnFiber) return;
const flags = unitOfWork.flags;
// 第一步 让父节点的firstEffect指向当前节点的firstEffect
// 注意,只有当父节点的 firstEffect 不存在时,我们才能将父节点的firstEffect指向当前节点的副作用链表表头
if (!returnFiber.firstEffect) {
returnFiber.firstEffect = unitOfWork.firstEffect;
}
// 第二步 将当前节点添加到它的副作用链表中,这里需要判断当前节点是否存在副作用链表
// 如果存在lastEffect,说明当前节点存在副作用链表
if (unitOfWork.lastEffect) {
if (returnFiber.lastEffect) {
returnFiber.lastEffect.nextEffect = unitOfWork.firstEffect;
}
returnFiber.lastEffect = unitOfWork.lastEffect;
}
// 前面两步都是在向父节点提交当前节点的副作用链表,不需要放在判断当前节点是否有副作用的条件语句里面
// flags > 1才说明该节点具有副作用,才可以提交到其父节点中
if (flags > 1) {
if (returnFiber.lastEffect) {
// 第三步,将当前节点添加到其副作用链表末尾
returnFiber.lastEffect.nextEffect = unitOfWork;
} else {
returnFiber.firstEffect = unitOfWork;
}
returnFiber.lastEffect = unitOfWork;
}
}
function deleteChild(returnFiber, childToDelete) {
// 需要删除的节点总是会被添加到父节点的副作用链表的最前面
// 当调用deleteChild时,父节点的副作用链表只包含被删除的节点
const last = returnFiber.lastEffect;
if (last) {
last.nextEffect = childToDelete;
returnFiber.lastEffect = childToDelete;
} else {
returnFiber.firstEffect = returnFiber.lastEffect = childToDelete;
}
childToDelete.nextEffect = null;
}
deleteChild(fiberA, fiberD);
completeUnitOfWork(fiberB);
completeUnitOfWork(fiberC);
completeUnitOfWork(fiberA);
deleteChild(fiberE, fiberH);
deleteChild(fiberE, fiberI);
completeUnitOfWork(fiberF);
completeUnitOfWork(fiberG);
completeUnitOfWork(fiberE);
printEffectList(HostRootFiber);
总结
以上就是 React17 在 render 阶段构建副作用链表的过程。React17 采用自底向上,逐级向父节点提交副作用链表的方式构建副作用链表。实际上这种方式比较麻烦,还难以理解。理论上可以采用数组存储这些具有副作用的节点,参考issue。在 React18 版本中,已经移除了这种构建方式。
思考以下demo,点击页面触发更新时,控制台的打印顺序如何?
render() {
const { step } = this.state;
return (
<div
style={{ height: "100px" }}
id={"A-" + step}
onClick={this.handleClick}
>
<div id={"B-" + step}>
<div id={"D"}>
<div id={"F-" + step}></div>
{!(step % 2) && <div id={"K-" + step}></div>}
</div>
<div id={"E-" + step}>
<div id={"G-" + step}></div>
{!!(step % 2) && <div id={"H-" + step}></div>}
{!(step % 2) && <div id={"I-" + step}></div>}
<div id={"J-" + step}></div>
</div>
</div>
<div id={"C-" + step}></div>
<div id="L">
{!(step % 2) && <div key="M" id={"M-" + step}></div>}
<div key="N" id={"N-" + step}></div>
{!!(step % 2) && <div key="M" id={"M-" + step}></div>}
</div>
</div>
);
}
看看你是否真的掌握了