深入理解React17构建fiber副作用链表算法

885 阅读13分钟

为什么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)。用于在将副作用链表打印出来,方便我们直观感受副作用链表的遍历顺序。

effect-list-01.jpg

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;
  }
}

effect-list-02.jpg

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,这三个节点都是具有更新的副作用,对应的副作用链表如下:

effect-list-03.jpg

我们可以简单的实现下这个算法:

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"。整个提交过程如下图所示:

effect-list-04.jpg

我们来完善一下我们的 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 节点的完成时。

effect-list-05.jpg

根据上图,我们修改一下我们的代码,在向父节点提交自己的副作用链表时,判断一下父节点是否已经存在了副作用链表,如果父节点已经存在副作用链表,则将自己的副作用链表追加到父节点的副作用链表后面:

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>
    );
  }

看看你是否真的掌握了