Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

深入理解React17构建fiber副作用链表算法源码 #13

Open
lizuncong opened this issue Jun 23, 2022 · 0 comments
Open

深入理解React17构建fiber副作用链表算法源码 #13

lizuncong opened this issue Jun 23, 2022 · 0 comments

Comments

@lizuncong
Copy link
Owner

本章介绍构建副作用链表的算法。

知识点

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

image

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

image

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

image

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

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

image

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

image

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

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 版本中,已经移除了这种构建方式。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant