本系列会实现一个简单的react,包含最基础的首次渲染,更新,hook,lane模型等等,本文是本系列的第一篇。这对于我也是一个很大的挑战,不过这也是一个学习和进步的过程,希望能坚持下去,一起加油!期待多多点赞!😘😘
本文致力于实现一个最简单的Suspense执行过程,代码均已上传至github,期待star!✨:
本文是系列文章,阅读的联系性非常重要!!
手写mini-react!超万字实现mount首次渲染流程🎉🎉
进击的hooks!实现react(反应)(反应)中的hooks架构和useState 🚀🚀
面试官问我 react scheduler 调度机制原理? 我却支支吾吾答不上来...😭😭
react(反应)能力值+1!useTransition是如何实现的?
期待点赞!😁😁
食用前指南!本文涉及到react的源码知识,需要对react有基础的知识功底,建议没有接触过react的同学先去官网学习一下基础知识,再看本系列最佳!
一. 基本概念
在 Suspense 内的任何子组件(数据)只要还在 pending 或者初始状态,则显示的是 fallback 的内容。等所有的数据加载完成才显示子组件,避免多次更新。
可以看做是 react 提供用了加载数据的一个标准,当加载到某个组件时,如果该组件本身或者组件需要的数据是未知的,需要动态加载,此时就可以使用 Suspense。Suspense 的加载过程:开始加载 -> 过渡 -> 完成后切换。
js
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
例如在进行数据请求的时候,拿到请求后的数据进行渲染是很常见的需求,但这不可避免的需要先渲染一次没有数据的页面,数据返回后再去重新渲染。想要暂停的就是第一次的无数据渲染。
通常我们在没有使用Suspense 时一般采用下面这种写法, 通过一个isLoading状态来显示加载中或数据。这样代码是不会有任何问题,但我们需要手动去维护一个isLoading 状态的值。
js
const [data, isLoading] = getData("/api");
if (isLoading) {
return <Spinner />;
}
return <MyComponent data={data} />;
当我们使用Suspense 后,使用方法会变为如下, 我们只需将进行异步数据获取的组件进行包裹,并将加载中组件通过fallback传入:
js
return (
<Suspense fallback={<Spinner />}>
<MyComponent />
</Suspense>
);
suspense是根据捕获子组件内的异常来实现决定展示哪个组件的。这有点类似于ErrorBoundary ,不过ErrorBoundary是捕获 Error 时就展示回退组件,而Suspense 捕获到的 Error 需要是一个Promise对象(并非必须是 Promise 类型,thenable 的都可以)。
我们知道 Promise 有三个状态,pending、fullfilled、rejected ,当我们进行远程数据获取时,会创建一个Promise,我们需要直接将这个Promise 作为Error进行抛出,由 Suspense 进行捕获,捕获后对该thenable对象的then方法进行回调注册thenable.then(retry) , 而 retry 方法就会开始一个调度任务进行更新,后面会详细讲。

在React中,与Suspense相关的特性有很多,例如:
- Lazy 组件
transition fallback(比如useTransition)- use
Offscreen组件- ...
先来缕一下思路:根据<Suspense/>组件的行为,也就是在包含的组件尚未加载完毕之前,先显示属性fallback中的内容,包含下面几种状态:
js
<Suspense fallback={<div>loading...</div>}>
<Cpn/>
</Suspense>
- 正常状态,
<Suspense/>渲染子孙组件 - 挂起状态,
<Suspense/>渲染fallback
其中,造成挂起状态的原因有很多,比如:
<Cpn/>或其子孙是懒加载组件<Cpn/>或其子孙触发并发更新(useTransition)<Cpn/>或其子孙使用use请求数据
凡是涉及 「初始状态」 -> 「中间状态」 -> 「结束状态」 的流程,都可以纳入<Suspense/>这个特性来处理。
思路一
按照Suspense的行为来看,我们很容易想到一种实现方式:只维护一个子组件,直接替换不就好了吗?如果包裹的子组件还在加载状态中,挂载fallback中的组件,当子组件准备完毕再将fallback卸载掉,按照正常流程挂载真实的子组件:
但是这样会有两个问题:
- 无法保存
children对应状态 - 切换后
children对应DOM需要完全销毁
思路二
存在两个child,beginWork时有选择的返回其中一个child,但另一个child也存在于fiber树中,使用某个标记控制两段child的切换。

这种方式可以使我们保存children对应状态,而且切换时也不需要频繁的创建销毁节点。
接下来的目标就是先在整个fiber架构中将这段suspense结构加入进去。
首先就涉及到fiber创建的流程,在此之前,首先还是了解一下整个fiber架构的执行流程。
二. fiber 的执行流程
上图是一个简易的整个react初始化的流程,在开始实现useRef前,有必要先来梳理一下react整个执行流程。
render
由于使用jsx由babel处理后的数据结构并不是真正的dom节点,而是element结构,一种拥有标记及子节点列表的对象,所以在拿到element对象后,首先要转化为fiber节点。
在fiber架构中,更新操作发生时,react会存在两棵fiber树(current树和workInProgress树),current树是上次更新生成的fiber树(初始化阶段为null),workInProgress树是本次更新需要生成的fiber树。双缓存树的机制是判断当前处于那个阶段(初始化/更新),复用节点属性的重要依据。在本次更新完成后两棵树会互相转换。
render阶段实际上是在内存中构建一棵新的fiber树(称为workInProgress树),构建过程是依照现有fiber树(current树)从root开始深度优先遍历再回溯到root的过程,这个过程中每个fiber节点都会经历两个阶段:beginWork和completeWork。
beginWork是向下调和的过程。就是由 fiberRoot 按照 child 指针逐层向下调和,而completeWork是向上归并的过程,如果有兄弟节点,会返回 sibling(同级)兄弟,没有返回 return 父级,一直返回到 FiebrRoot。
组件的状态计算、diff的操作以及render函数的执行,发生在beginWork阶段,effect链表的收集、被跳过的优先级的收集,发生在completeWork阶段。构建workInProgress树的过程中会有一个workInProgress的指针记录下当前构建到哪个fiber节点,这是react更新任务可恢复的重要原因之一。
commit
在render阶段结束后,会进入commit阶段,该阶段不可中断,主要是去依据workInProgress树中有变化的那些节点(render阶段的completeWork过程收集到的effect链表),去完成DOM操作,将更新应用到页面上,除此之外,还会异步调度useEffect以及同步执行useLayoutEffect。
commit 细分可以分为三个阶段:
Before mutation阶段:执行 DOM 操作前
没修改真实的 DOM ,是获取 DOM 快照的最佳时期,如果是类组件有 getSnapshotBeforeUpdate,会在这里执行。
mutation阶段:执行 DOM 操作
对新增元素,更新元素,删除元素。进行真实的 DOM 操作。
layout阶段:执行 DOM 操作后
DOM 更新完毕。
Suspense类型的节点是怎么被创建的?
还是用上面这段代码为🌰:
js
function App() {
return (
<div>
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
</div>
)
}
这段 jsx 代码最终会被 babel 转换为 通过 jsx() 函数的调用形式

我们在jsx函数中返回生成的element对象,保存一个节点所属的属性,子级节点等信息。
js
const element = {
// 类型
$$typeof: REACT_ELEMENT_TYPE,
type,
key,
ref,
props,
}
但是不同类型的element对象表现形式是不同的,对于普通的节点类型生成的element对象来说,我们标记为REACT_ELEMENT_TYPE类型:
js
export const REACT_ELEMENT_TYPE = supportSymbol
? Symbol.for('react.element')
: 0xeac7;
但是需要特别注意的是,函数组件类型(FunctionComponent)会将整个函数体保存在element对象的type属性中。
js
// 普通dom类型
<div></div>
// 函数组件
<App />
function App() {}
所以在处理普通的dom类型生成的element对象和函数组件类型的element对象是不同的,后者需要执行函数组件才能获取到子级。
三种对象的关系如下图:

那么回到本章的主角,很明显Suspense标签也需要一个标记便于后续生成fiber节点时进行判断:
js
export const REACT_SUSPENSE_TYPE = supportSymbol
? Symbol.for('react.suspense')
: 0xead1;
当jsx函数在为Suspense标签创建element对象时,标记为REACT_SUSPENSE_TYPE。
在整个应用的element对象都被创建完成后,开始创建fiber树的流程,也就是上文中提到的render阶段,整个render阶段会经历两个处理过程:beginWork和completeWork。beginWork流程负责根据不同的类型创建创建子级fiber,收集副作用等等。completeWork流程负责根据不同的类型创建真实DOM(只是创建,并不挂载到页面中)等。
但是在beginWork判断类型时,使用的是fiber节点的类型,而并非element对象的类型:
js
export const beginWork = (wip, renderLane) => {
// 比较,返回子fiberNode
switch (wip.tag) {
// 根节点
case HostRoot:
// ...
// dom节点,如div,span...
case HostComponent:
// ...
// 文本节点
case HostText:
// ...
// 函数类型节点
case FunctionComponent:
// ...
// Fragment
case Fragment:
// ...
}
return null;
};
这说明在进入当前fiber节点的处理之前已经被创建了,所以beginWork负责根据子级的element对象创建子级的fiber节点。详细参考:手写mini-react!超万字实现mount首次渲染流程🎉🎉
按照我们上面的例子:

在div的fiber节点进入beginWork函数后根据子element对象生成子fiber节点(Suspense节点):
分为三种情况,对应type的三种值:
string类型:类似于div,span等真实的dom标签名,代表真实dom节点function类型:代表函数组件类型。如:<App />- 等于
REACT_SUSPENSE_TYPE:代表Suspense标签
js
export function createFiberFromElement(element) {
const { type, key, props, ref } = element;
// 默认为函数组件
let fiberTag = FunctionComponent;
// string类型为dom类型
if (typeof type === 'string') {
fiberTag = HostComponent;
} else if (type === REACT_SUSPENSE_TYPE) {
// suspense
fiberTag = SuspenseComponent;
} else if (typeof type !== 'function') {
console.warn('为定义的type类型', element);
}
// 创建fiber
const fiber = new FiberNode(fiberTag, props, key);
fiber.type = type;
fiber.ref = ref;
return fiber;
}
这里为什么要用type === REACT_SUSPENSE_TYPE判断呢?
因为Suspense标签在创建element对象时,直接将REACT_SUSPENSE_TYPE这个标记传入jsx()函数。类似于函数组件直接将函数体保存在element对象的type属性中,Suspense直接将REACT_SUSPENSE_TYPE标记保存在了type属性中。
之后进入Suspense的fiber节点开始beginWork流程。
三. Suspense 的整体结构
根据我们上面待实现的整体结构,在处理到<Suspense />节点的时候他的子节点可能有两种分支情况,一种是挂起状态(也就是加载中时,正常的子fiber节点暂不显示),一种是正常状态的子fiber树。
在beginWork中加入Suspense的处理流程:
js
export const beginWork = (wip, renderLane) => {
// 比较,返回子fiberNode
switch (wip.tag) {
case HostRoot:
// ...
case HostComponent:
// ...
case HostText:
// ...
case FunctionComponent:
// ...
case SuspenseComponent:
return updateSuspenseComponent(wip);
default:
if (__DEV__) {
console.warn('beginWork未实现的类型');
}
break;
}
return null;
};
updateSuspenseComponent函数用来创建Suspense的子节点,由于在初始化和更新阶段对于fiber节点的操作是不同的,所以在创建子树的时候还需要考虑当前是处于mount还是update,整个创建子树的流程有以下几种情况需要分别实现:
-
mount初始化阶段- 挂起状态
- 正常状态
-
update更新阶段- 挂起状态
- 正常状态
一共对应四种流程:
mount时正常流程(对应方法mountSuspensePrimaryChildren)update时正常流程(对应方法updateSuspensePrimaryChildren)mount时挂起流程(对应方法mountSuspenseFallbackChildren)update时挂起流程(对应方法updateSuspenseFallbackChildren)

js
function updateSuspenseComponent(workInProgress) {
// 获取current树对应节点
const current = workInProgress.alternate;
// 获取属性
const nextProps = workInProgress.pendingProps;
// 挂起状态?是否显示fallback
let showFallback = false;
// 是否是挂起状态?
const didSuspend = (workInProgress.flags & DidCapture) !== NoFlags;
// 设置showFallback为true 并移除挂起标记
if (didSuspend) {
showFallback = true;
workInProgress.flags &= ~DidCapture;
}
// 分别获取子级:offscreen子级
const nextPrimaryChildren = nextProps.children;
// fragment子级
const nextFallbackChildren = nextProps.fallback;
// 保存suspense组件栈
pushSuspenseHandler(workInProgress);
if (current === null) {
// mount阶段
if (showFallback) {
// 挂起
// 初始化挂起阶段结构 fragement
return mountSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren
);
} else {
// 正常
// 初始化正常流程结构 offscreen
return mountSuspensePrimaryChildren(workInProgress, nextPrimaryChildren);
}
} else {
// update阶段
if (showFallback) {
// 挂起
// 更新阶段挂起结构 fragement
return updateSuspenseFallbackChildren(
workInProgress,
nextPrimaryChildren,
nextFallbackChildren
);
} else {
// 正常
// 更新阶段正常结构 offscreen
return updateSuspensePrimaryChildren(workInProgress, nextPrimaryChildren);
}
}
}
DidCapture标记当前是否处于挂起状态,至于它是如何被标记在fiber节点上的,会在下文实现。
js
export const DidCapture = 0b1000000;
由于可能存在多个Suspense组件嵌套,所以使用一个栈结构来保存和区分当前处于哪个Suspense组件中:
js
const suspenseHandlerStack = [];
export function pushSuspenseHandler(handler) {
suspenseHandlerStack.push(handler);
}
在获取两个状态的子级时,通过children和fallback属性获取。原因是在 babel 编译后children和fallback被设置为同级的属性保存在Suspense中。

js
// 分别获取子级:offscreen子级
const nextPrimaryChildren = nextProps.children;
// fragment子级
const nextFallbackChildren = nextProps.fallback;
接下来创建每种可能性的子树结构。
1. 创建mount时挂起流程
挂起流程仅仅是一个中间状态,其实终极目标还是要挂载真实的节点,所以在创建挂起流程时,还需要一起创建正常流程的节点。
正常流程以节点Offscreen节点开始,挂起流程以Fragment节点开始。Offscreen节点与Fragment节点互为兄弟节点。
js
function mountSuspenseFallbackChildren(
workInProgress,
primaryChildren,
fallbackChildren
) {
const primaryChildProps = {
mode: 'hidden',
children: primaryChildren
};
// 创建offscreen 正常流程分支
const primaryChildFragment = createFiberFromOffscreen(primaryChildProps);
// 创建fragment 挂起流程分支
const fallbackChildFragment = createFiberFromFragment(fallbackChildren, null);
// 通过return指向父节点
primaryChildFragment.return = workInProgress;
fallbackChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
workInProgress.child = primaryChildFragment;
return fallbackChildFragment;
}
在创建正常流程的子树时同是保存此刻Suspense的状态:hidden/visible,此时为挂起状态,所以mode属性保存为hidden。
js
export const OffscreenComponent = 14;
// 创建Offscreen节点
export function createFiberFromOffscreen(pendingProps: OffscreenProps) {
const fiber = new FiberNode(OffscreenComponent, pendingProps, null);
return fiber;
}
创建Fragment节点时,由于只将它作为一个包裹组件使用,所以直接将子节点作为属性传递。后续取值时,获取子节点可以直接通过获取属性的方式。
js
export const Fragment = 7;
// 创建Fragment节点
export function createFiberFromFragment(elements, key) {
const fiber = new FiberNode(Fragment, elements, key);
return fiber;
}
2. 创建mount时正常流程
当需要创建正常流程的节点时,此时已经需要显示真正的dom节点,所以不需要创建中间状态(挂起流程)。
js
function mountSuspensePrimaryChildren(
workInProgress,
primaryChildren
) {
const primaryChildProps = {
mode: 'visible',
children: primaryChildren
};
// 创建正常流程
// 不需要创建虚拟节点
const primaryChildFragment = createFiberFromOffscreen(primaryChildProps);
// 构建与父节点的连接
workInProgress.child = primaryChildFragment;
primaryChildFragment.return = workInProgress;
return primaryChildFragment;
}
3. 创建update时挂起流程
在更新阶段由于current树已经存在上次已经创建过的节点,所以主要是依靠上次的fiber节点进行复用。
由于挂起流程的子树不一定每次都会存在,所以通过真正的子树Offscreen节点的sibling节点获取挂起流程的子树。
-
获取
current节点(双缓存树,上一次更新的fiber节点) -
获取上一次更新的
Offscreen节点,获取上一次更新的Fragment节点 -
更新本次
Offscreen节点 -
上一次更新是否存在
Fragment节点(上一次更新是否有过挂起流程)?- 有:更新旧
Fragment节点 - 无:重新创建
Fragment节点
- 有:更新旧
-
更新节点之间的联系
js
function updateSuspenseFallbackChildren(
workInProgress,
primaryChildren,
fallbackChildren
) {
// 此current是current树中的Suspense节点
const current = workInProgress.alternate;
// 获取上一次更新的Offscreen节点
const currentPrimaryChildFragment = current.child;
// 通过Offscreen节点的sibling获取上一次挂起流程时的Fragment节点
const currentFallbackChildFragment =
currentPrimaryChildFragment.sibling;
const primaryChildProps = {
mode: 'hidden',
children: primaryChildren
};
// 更新对应旧的Offscreen节点
const primaryChildFragment = createWorkInProgress(
currentPrimaryChildFragment,
primaryChildProps
);
let fallbackChildFragment;
if (currentFallbackChildFragment !== null) {
// 可以复用
fallbackChildFragment = createWorkInProgress(
currentFallbackChildFragment,
fallbackChildren
);
} else {
// 创建新的挂起流程Fragment节点
fallbackChildFragment = createFiberFromFragment(fallbackChildren, null);
// 增加新增标记
fallbackChildFragment.flags |= Placement;
}
// 更新指针连接 同mount时的创建流程
fallbackChildFragment.return = workInProgress;
primaryChildFragment.return = workInProgress;
primaryChildFragment.sibling = fallbackChildFragment;
workInProgress.child = primaryChildFragment;
return fallbackChildFragment;
}
createWorkInProgress函数是一个更新时复用fiber的方法,在内部会通过alternate指针获取current树的节点,然后更新节点的pendingProps属性。返回的结果可以看做是更新完属性后的对应current树的 fiber节点。
由于我们在mount阶段时是直接通过创建的不同类型的fiber节点,所以可以直接通过更新fiber节点的方法来更新我们的Offscreen和Fragment节点。
4. 创建update时正常流程
正常流程的更新与挂起流程最大的区别是,如果上一次更新存在挂起流程的节点(Offscreen.sibling !== null),本次进入正常流程需要删掉上一次遗留的起流程的节点Fragment。
- 获取
current树中的Offscreen节点 - 更新
Offscreen节点 - 如果还存在上一次的挂起流程的节点,标记删除
js
function updateSuspensePrimaryChildren(
workInProgress,
primaryChildren
) {
// 获取current树中对应fiber节点
const current = workInProgress.alternate;
// 获取Offscreen节点(current树中)
const currentPrimaryChildFragment = current.child
// 获取Fragment节点(current树中)
const currentFallbackChildFragment = currentPrimaryChildFragment.sibling;
const primaryChildProps = {
mode: 'visible',
children: primaryChildren
};
// 更新Offscreen节点
const primaryChildFragment = createWorkInProgress(
currentPrimaryChildFragment,
primaryChildProps
);
// 更新链接
primaryChildFragment.return = workInProgress;
primaryChildFragment.sibling = null;
workInProgress.child = primaryChildFragment;
// 如果上次更新的Fragment节点还存在
if (currentFallbackChildFragment !== null) {
// 标记删除并将节点加入deletions列表
const deletions = workInProgress.deletions;
if (deletions === null) {
workInProgress.deletions = [currentFallbackChildFragment];
// 标记删除
workInProgress.flags |= ChildDeletion;
} else {
deletions.push(currentFallbackChildFragment);
}
}
return primaryChildFragment;
}
Offscreen节点
在Suspense的beginWork流程中根据正常的流程会创建Offscreen节点。
而Offscreen节点的子树是真正需要渲染的部分,所以创建Offscreen节点的子树时按照正常fiber架构创建子树的方式来执行。
beginWork时添加对Offscreen类型节点的支持:
diff
export const beginWork = (wip, renderLane) => {
// 比较,返回子fiberNode
switch (wip.tag) {
case HostRoot:
// ...
case HostComponent:
// ...
case HostText:
// ...
case FunctionComponent:
// ...
case SuspenseComponent:
return updateSuspenseComponent(wip);
// 加入对OffscreenComponent类型的支持
++ case OffscreenComponent:
++ return updateOffscreenComponent(wip);
default:
if (__DEV__) {
console.warn('beginWork未实现的类型');
}
break;
}
return null;
};
reconcileChildren函数创建子fiber节点,会经过diff、处理副作用等逻辑。
js
function updateOffscreenComponent(workInProgress) {
// 获取属性,包含子节点列表
const nextProps = workInProgress.pendingProps;
const nextChildren = nextProps.children;
// 创建子fiber逻辑
reconcileChildren(workInProgress, nextChildren);
return workInProgress.child;
}
Fragment节点
增加对Fragment节点的支持:
diff
export const beginWork = (wip, renderLane) => {
// 比较,返回子fiberNode
switch (wip.tag) {
case HostRoot:
// ...
case HostComponent:
// ...
case HostText:
// ...
case FunctionComponent:
// ...
case SuspenseComponent:
return updateSuspenseComponent(wip);
// 加入对OffscreenComponent类型的支持
case OffscreenComponent:
return updateOffscreenComponent(wip);
// 增加对Fragment节点的支持
++ case Fragment:
++ return updateFragment(wip);
default:
if (__DEV__) {
console.warn('beginWork未实现的类型');
}
break;
}
return null;
};
创建Fragment类型的节点时,由于直接将子节点当作属性设置,所以直接通过获取属性值的方式直接得到所有子级。
js
function updateFragment(wip) {
const nextChildren = wip.pendingProps;
// 创建子级fiber节点
reconcileChildren(wip, nextChildren);
return wip.child;
}
挂起流程中Fragment节点下的子节点fullback中保存一个中间状态的组件(通常是<Loading />组件)。此时可以直接当做普通的函数类型组件处理即可。
四. Suspense 工作流程
由于我们为Suspense组件增加了两种执行流程的分支:正常流程和挂起流程。
render
所以在beginWork函数中创建完相应的fiber节点后,就需要对Suspense组件completeWork归的过程中在Suspense组件的根节点做出相应的标记,标记当前这个Suspense组件分支是处于什么状态?(visible组件显示/hidden组件挂起)作为在最后的commit阶段操作真实dom时的依据。

上图为正常的fiber节点所要经历的整个render阶段,beginWork和completeWork函数完成整个fiber树的回溯过程。
completeWork函数中也需要根据tag属性对不同类型的节点进行不同的处理:
js
export const Visibility = 0b0100000;
export const completeWork = (wip) => {
const newProps = wip.pendingProps;
const current = wip.alternate;
switch (wip.tag) {
case HostComponent:
// ...
case HostText:
// ...
case HostRoot:
case FunctionComponent:
// 对suspense的操作都在SuspenseComponent中进行
case Fragment:
case OffscreenComponent:
// ...
case SuspenseComponent:
// 当前已到达该Suspense组件分支的根节点,出栈
popSuspenseHandler();
// 获取当前Suspense组件状态
const offscreenFiber = wip.child;
const isHidden = offscreenFiber.pendingProps.mode === 'hidden';
const currentOffscreenFiber = offscreenFiber.alternate;
// update流程
if (currentOffscreenFiber !== null) {
const wasHidden = currentOffscreenFiber.pendingProps.mode === 'hidden';
// 本次更新与上一次有变化
if (isHidden !== wasHidden) {
// 可见性变化
offscreenFiber.flags |= Visibility;
}
} else if (isHidden) {
// mount阶段 hidden
offscreenFiber.flags |= Visibility;
}
return null;
default:
if (__DEV__) {
console.warn('未处理的completeWork情况', wip);
}
break;
}
};
如果在Suspense节点到达completeWork函数,说明当前已经到达该Suspense组件分支的根节点,将当前的节点从suspenseHandlerStack中出栈。
js
// 出栈
export function popSuspenseHandler() {
suspenseHandlerStack.pop();
}
为什么要在SuspenseComponent时进行处理呢,因为SuspenseComponent代表当前Suspense组件分支的根节点,无论是正常流程还是挂起流程最终在回溯的过程中都会回到这个节点再继续向上查找。所以在到达这个节点时标记。
由于每一次触发的更新有可能会进入任何一个流程,所以需要判断一下几种情况:
completeWork时对比current Offscreen mode与wip Offscreen mode,如果发现下述情况,则标记Visibility effectTag:
mode从hidden变为visiblemode从visible变为hiddencurrent === null&&hidden
commit
在render阶段标记完成后,这时已经形成了一个完整的fiber树的结构。然后开启commit阶段,开始根据fiber节点中不同的副作用标记应用到真实的dom节点中。
上文已经提到过,在commit阶段存在三个阶段,其中操作和应用副作用的逻辑是在commitMutationEffects函数中:
js
function commitRoot(root: FiberRootNode) {
const finishedWork = root.finishedWork;
// ...省略
// finishedWork代表已经完全生成完的整棵fiber树
commitMutationEffects(finishedWork, root);
// 切换current树
root.current = finishedWork;
// ...省略
}
在commitMutationEffects函数中同样会对每个fiber节点进行深度优先遍历,然后回溯,整个过程类似render阶段遍历生成fiber节点的过程。
遍历到的每个fiber节点会执行commitMutationEffectsOnFiber函数,根据副作用标记操作真实dom。
js
const commitMutationEffectsOnFiber = (
finishedWork,
root
) => {
const { flags, tag } = finishedWork;
// 新增标记
if ((flags & Placement) !== NoFlags) {
// ...
}
// 更新标记
if ((flags & Update) !== NoFlags) {
// ...
}
// 删除标记
if ((flags & ChildDeletion) !== NoFlags) {
// ...
}
// Suspense
if ((flags & Visibility) !== NoFlags && tag === OffscreenComponent) {
// 是否是hidden状态
const isHidden = finishedWork.pendingProps.mode === 'hidden';
hideOrUnhideAllChildren(finishedWork, isHidden);
// 操作完成后移除标记
finishedWork.flags &= ~Visibility;
}
};
在commitMutationEffectsOnFiber函数中判断是否存在Visibility标记,并且需要在OffscreenComponent节点上处理。OffscreenComponent节点是Suspense组件下正常流程分支的第一个节点,本身是一个虚构的类型,它的子节点才是真正要显示的子树。
根据标记控制子树的真实dom节点显示或者隐藏,但是在fiber树中可能存在各种不同类型的情况,比如可能存在Fragment,FunctionComponent等非dom类型的fiber类型。可能存在多个根节点,如:
js
function Cpn() {
return (
<p>123</p>
)
}
// 情况1,一个host节点:
<Suspense fallback={<div>loading...</div>}>
<Cpn/>
</Suspense>
// 情况2,多个host节点:
<Suspense fallback={<div>loading...</div>}>
<Cpn/>
<div>
<p>你好</p>
</div>
</Suspense>
这时候就需要寻找到所有子树顶层Host节点,在顶层的Host节点上设置显示或者隐藏:
整个过程从根节点开始,查找顶层Host节点(例如div,span等HostComponent类型节点)。从上到下深度优先遍历,如果到达最后一个子节点,再继续寻找兄弟节点。兄弟节点不存在,则向上回归,直到回到一开始遍历的节点,最后完成整个寻找过程。中间查找到顶层的Host节点,执行切换显示隐藏的功能(callback函数)。
由于文本节点不可能是顶层的根节点,其子级只能是字符串形式,所以不需要将文本节点保存为hostSubtreeRoot。
由于可能在Suspense组件下同时存在多个根节点,所以在切换兄弟节点时需要将hostSubtreeRoot重置。
如下图,按照情况二查找:

js
function findHostSubtreeRoot(
finishedWork,
callback
) {
let hostSubtreeRoot = null;
let node = finishedWork;
while (true) {
if (node.tag === HostComponent) {
if (hostSubtreeRoot === null) {
// 还未发现 root,当前就是
hostSubtreeRoot = node;
callback(node);
}
} else if (node.tag === HostText) {
if (hostSubtreeRoot === null) {
// 还未发现 root,text可以是顶层节点
callback(node);
}
} else if (
node.tag === OffscreenComponent &&
node.pendingProps.mode === 'hidden' &&
node !== finishedWork
) {
// 隐藏的OffscreenComponent跳过
} else if (node.child !== null) {
node.child.return = node;
node = node.child;
continue;
}
// 已经回到初始的位置,返回
if (node === finishedWork) {
return;
}
// 如果没有兄弟节点,向上归并
while (node.sibling === null) {
if (node.return === null || node.return === finishedWork) {
return;
}
if (hostSubtreeRoot === node) {
hostSubtreeRoot = null;
}
node = node.return;
}
// 去兄弟节点寻找,此时当前子树的host root可以移除了
if (hostSubtreeRoot === node) {
hostSubtreeRoot = null;
}
node.sibling.return = node.return;
node = node.sibling;
}
}
fiber节点中的stateNode属性保存着真实的dom实例,通过对dom设置css属性来控制显示隐藏。
而对于文本类型的实例,直接设置内容就可以了,当需要隐藏的时候,设置内容为空。
js
function hideOrUnhideAllChildren(finishedWork, isHidden) {
findHostSubtreeRoot(finishedWork, (hostRoot) => {
// 获取dom实例
const instance = hostRoot.stateNode;
if (hostRoot.tag === HostComponent) {
// HostComponent类型
isHidden ? hideInstance(instance) : unhideInstance(instance);
} else if (hostRoot.tag === HostText) {
// 文本类型
isHidden
? hideTextInstance(instance)
: unhideTextInstance(instance, hostRoot.memoizedProps.content);
}
});
}
为dom实例设置样式属性display的值,对dom进行显示隐藏的切换。
js
// 隐藏
export function hideInstance(instance) {
const style = instance.style;
style.setProperty('display', 'none', 'important');
}
// 显示
export function unhideInstance(instance) {
const style = instance.style;
style.display = '';
}
对于文本节点来说,直接对内容值进行赋值或清除。
js
// 隐藏
export function hideTextInstance(textInstance) {
textInstance.nodeValue = '';
}
// 显示
export function unhideTextInstance(textInstance, text) {
textInstance.nodeValue = text;
}
在开始触发Suspense组件的动作之前,先来看一下 react 中一个实验性的 hook --- use。
五. use(实验性hook)
use 是一个 React Hook,它可以让你读取类似于 Promise 或 context 的资源的值。
js
const value = use(resource);
当使用 Promise 调用 use Hook 时,它会与 Suspense 和 错误边界 集成。当传递给 use 的 Promise 处于 pending 时,调用 use 的组件也会 挂起 。如果调用 use 的组件被包装在 Suspense 边界内,将显示后备 UI。一旦 Promise 被解决,Suspense 后备方案将被使用 use Hook 返回的数据替换。如果传递给 use 的 Promise 被拒绝,将显示最近错误边界的后备 UI。
js
import { use } from 'react';
function MessageComponent({ messagePromise }) {
// promise
const message = use(messagePromise);
// context
const theme = use(ThemeContext);
// ...
根据use这个hook的特性来看,我们很容易与Suspense来结合起来利用,他可以使Suspense在合适的时机被触发。
js
import { use, Suspense } from "react";
function Message({ messagePromise }) {
const messageContent = use(messagePromise);
return <p>Here is the message: {messageContent}</p>;
}
export function MessageContainer({ messagePromise }) {
return (
<Suspense fallback={<p>⌛Downloading message...</p>}>
<Message messagePromise={messagePromise} />
</Suspense>
);
}
由于 Message 被包裹在 Suspense 中,所以在 Promise 解决之前将显示后备方案。当 Promise 被解决后,use Hook 将读取值,然后 Message 组件将显示正常内容。

利用use判断 Promise 的状态与Suspense组件相结合,实现加载动画的中间状态。
use函数的实现思路很清晰,接收两种类型的参数;Promise和Context,本次我们只关注Promise。如果对Context功能的实现感兴趣可以看这篇文章:进击的hooks!useContext 执行机制解析 🚀🚀
根据Promise A+规范,它里面需要具备then方法,所以根据这个特性,可以作为判断是否是Promise的依据。
js
function use(usable) {
if (usable !== null && typeof usable === 'object') {
if (typeof usable.then === 'function') {
// 根据是否拥有then方法判断
const thenable = usable;
// 处理thenable
return trackUsedThenable(thenable);
} else if (usable.$$typeof === REACT_CONTEXT_TYPE) {
// context
}
}
throw new Error('不支持的use参数 ' + usable);
}
thenable通常指有 then 方法的对象。
trackUsedThenable根据thenable的状态进行不同的处理,如果thenable状态已经确定fulfilled/rejected,直接将值返回。如果状态是pending,则为thenable添加then函数,待状态确定后将值返回。
更多Promise的知识:基于 Promise A+ 规范使用Typescript实现Promise
js
// 空函数
function noop() {}
// 全局变量 保存thenable
let suspendedThenable = null;
export function trackUsedThenable(thenable) {
switch (thenable.status) {
// 状态已变更为成功
case 'fulfilled':
return thenable.value;
// 状态已变更为失败
case 'rejected':
throw thenable.reason;
default:
// 上一次已经进入过,默认空函数
if (typeof thenable.status === 'string') {
thenable.then(noop, noop);
} else {
//状态未确定,pending
const pending = thenable;
pending.status = 'pending';
pending.then(
// fulfilled
(val) => {
if (pending.status === 'pending') {
const fulfilled = pending;
fulfilled.status = 'fulfilled';
fulfilled.value = val;
}
},
// rejected
(err) => {
if (pending.status === 'pending') {
const rejected = pending;
rejected.reason = err;
rejected.status = 'rejected';
}
}
);
}
}
// 保存在全局
suspendedThenable = thenable;
// 抛出错误
throw SuspenseException;
}
那么use的工作怎么能和Suspense的工作流程结合到一起呢?
js
export const SuspenseException = new Error(
'这不是个真实的错误,而是Suspense工作的一部分。如果你捕获到这个错误,请将它继续抛出去'
);
真正的奥秘正是在最后抛出的错误上。由于在创建fiber节点时,react会不断循环调用workLoop方法根据每个element节点执行beginWork函数。当执行到Suspense节点下面的某个函数组件时,为其创建fiber节点需要执行整个函数组件以获取return出来的dom。所以在执行到内部use函数时,use函数在**pending**状态时抛出一个错误 ,这个错误会在执行workLoop方法的函数中被捕获到。所以 react 在render的过程中就可以得知该Suspense组件下存在挂起状态。

js
function renderRoot(root, lane, shouldTimeSlice) {
// ...
do {
try {
// ...
// 开始构造fiber节点入口
// 注意这里为了便于理解,并没有使用并发更新的模式
workLoop()
} catch (e) {
if (__DEV__) {
console.warn('workLoop发生错误', e);
}
// 捕获错误
handleThrow(root, e);
}
} while (true);
// ...
}
在捕获到错误之后,获取当前的thenable对象,保存在全局变量workInProgressThrownValue中。
js
const NotSuspended = 0;
const SuspendedOnData = 6;
// 后续用于流程判断的标记 可以先简单看作是否处于suspense逻辑中的判断依据
let workInProgressSuspendedReason = NotSuspended;
let workInProgressThrownValue = null;
function handleThrow(root, thrownValue) {
if (thrownValue === SuspenseException) {
// 后续用于流程判断的标记
workInProgressSuspendedReason = SuspendedOnData;
thrownValue = getSuspenseThenable();
}
// 保存
workInProgressThrownValue = thrownValue;
}
// suspendedThenable是use函数中保存thenable的全局变量
function getSuspenseThenable() {
// 在获取thenable时,判断是否有值
if (suspendedThenable === null) {
throw new Error('应该存在suspendedThenable');
}
const thenable = suspendedThenable;
// 重置 suspendedThenable
suspendedThenable = null;
return thenable;
}
六. Suspense 如何被触发?
unwind
上面Suspense的执行流程我们一共定义了四种,但是在一次更新中可能不止会触发一个流程。试想这样一种场景,当某一次更新时,在<Suspense />组件的子组件中使用了use这个hook,由于use函数的返回值此时尚未处于完成状态,所以需要显示中间状态,也就是需要创建挂起流程的节点。
但是问题是,目前beginWork函数创建fiber节点已经进行到了正常流程下面的子树,按照我们之前实现的挂起流程的逻辑,是将挂起流程的子树节点放在Suspense节点的直接子级。
例如下图中在<Cpn />组件中定义的use函数发生状态变更,需要创建挂起流程的fiber节点,所以需要先从<Cpn />这个函数节点开始向上寻找,找到最近的父级Suspense节点,然后创建挂起流程的fiber节点。这个过程是render阶段的一个新流程 ------ unwind流程。
下图为状态变更时(visible --> hidden),beginWork执行流向。

对于demo中的例子,经历了:
- 正常流程对应
render阶段 - 遇到
use,进入挂起流程 - 进入挂起流程对应
render阶段 - 进入挂起流程对应
commit阶段(渲染loading) - 请求返回后,进入正常流程对应
render阶段 - 进入正常流程对应
commit阶段(渲染Cpn)
Suspense涉及到render阶段的一个新流程 ------ unwind流程
总结学到的三种流程:
beginWork:往下深度优先遍历completeWork:往上深度优先遍历unwind:往上遍历祖辈
数据返回后的正常流程:
所以在捕获到use函数抛出的错误后,由于调用workLoop时使用while循环,在抛出错误后还会继续调用,此时在下次进入workLoop之前需要开始进行unwind流程,然后定义一个时机去触发一次更新。
js
function renderRoot(root, lane, shouldTimeSlice) {
// ...
do {
try {
// 上一次抛出了错误
if (
workInProgressSuspendedReason !== NotSuspended &&
workInProgress !== null
) {
const thrownValue = workInProgressThrownValue;
// 重置为初始状态,防止后续执行时出错
workInProgressSuspendedReason = NotSuspended;
workInProgressThrownValue = null;
// unwind的执行流程
throwAndUnwindWorkLoop(root, workInProgress, thrownValue, lane);
}
// 正常的流程
workLoop();
break;
} catch (e) {
if (__DEV__) {
console.warn('workLoop发生错误', e);
}
// 抛出错误
handleThrow(root, e);
}
} while (true);
// ...
}
那么这个所谓的时机是什么时候呢?由于我们抛出一个Promise,它的完成时机我们并不知道,所以这个时机就是在Promise状态确定之后,状态由pending变为fulfilled或者pending变为rejected。在抛出的Promise状态确定之后,我们需要触发一次更新。在返回结果之前,利用unwind回到最近的Suspense,切换为挂起状态。
如何查找最近的Suspense?还记得前面我们在进入Suspense节点的beginWork时保存了一个Suspense节点的栈结构(后入先出):
js
const suspenseHandlerStack = [];
export const beginWork = (wip, renderLane) => {
// ...
case SuspenseComponent:
return updateSuspenseComponent(wip);
// ...
}
// Suspense 节点执行到 beginwork
function updateSuspenseComponent(workInProgress: FiberNode) {
// ...
// 入栈
// 相当于 suspenseHandlerStack.push(handler);
pushSuspenseHandler(workInProgress);
// ...
}
整个fiber树的遍历形式是深度优先遍历,然后通过beginWork向下遍历,completeWork回溯,所以在completeWork流程回到Suspense节点时要出栈:
js
export const completeWork = (wip) => {
// 递归中的归
const newProps = wip.pendingProps;
const current = wip.alternate;
switch (wip.tag) {
// ...
case SuspenseComponent:
// 出栈 suspenseHandlerStack.pop();
popSuspenseHandler();
// ...
return null;
default:
if (__DEV__) {
console.warn('未处理的completeWork情况', wip);
}
break;
}
};
由于触发的更新任务只能是一个异步任务的回调函数的形式,所以回溯到Suspense节点的unwind流程会先执行。
js
function throwAndUnwindWorkLoop(
root,
unitOfWork,
thrownValue,
lane
) {
// 创建触发更新任务
throwException(root, thrownValue, lane);
// 回溯到Suspense节点
unwindUnitOfWork(unitOfWork);
}
取出最近的Suspense节点,为其加入ShouldCapture标记,代表该Suspense节点下面需要进行unwind流程,当unwind流程回溯到达此Suspense节点后,再将标记更改为DidCapture,代表需要切换到挂起状态。接下来就会开始在beginWork中执行挂起流程的逻辑。

js
// 获取最新的(最近)Suspense
export function getSuspenseHandler() {
return suspenseHandlerStack[suspenseHandlerStack.length - 1];
}
export function throwException(root, value, lane) {
// 判断是否为合法的use函数返回值 一个Promise
if (
value !== null &&
typeof value === 'object' &&
typeof value.then === 'function'
) {
const weakable = value;
// 获取最新的(最近)Suspense
const suspenseBoundary = getSuspenseHandler();
// 为该Suspense节点标记ShouldCapture
if (suspenseBoundary) {
suspenseBoundary.flags |= ShouldCapture;
}
// 创建更新任务
attachPingListener(root, weakable, lane);
}
}
由于创建的更新任务不是立即被执行,所以为了避免在等待的过程中多次被调用执行同一个函数的情况,使用WeakMap缓存每次的wakeable作为key,保存wakeable的lane(优先级标记)。
js
WeakMap{ wakeable: Set[lane1, lane2, ...]}
只为第一次进入此逻辑的优先级创建更新任务。
为wakeable挂载then函数,Promise状态确定后会执行此then函数,执行时需要清除掉pingCache缓存。
js
function attachPingListener(
root,
wakeable,
lane
) {
let pingCache = root.pingCache;
let threadIDs;
// 定义缓存值
if (pingCache === null) {
// 无缓存时,根据wakeable为key创建
threadIDs = new Set();
pingCache = root.pingCache = new WeakMap();
pingCache.set(wakeable, threadIDs);
} else {
threadIDs = pingCache.get(wakeable);
if (threadIDs === undefined) {
threadIDs = new Set();
pingCache.set(wakeable, threadIDs);
}
}
if (!threadIDs.has(lane)) {
// 第一次进入
threadIDs.add(lane);
// 定义then函数
function ping() {
if (pingCache !== null) {
pingCache.delete(wakeable);
}
// 调度更新的入口函数,可以简单看作调用了一次更新
ensureRootIsScheduled(root);
}
wakeable.then(ping, ping);
}
}
接下来需要回溯到Suspense节点,unwind流程,找到Suspense节点后,将节点赋值给workInProgress,workInProgress作为render阶段将要被执行的节点。未找到则根据return继续向上寻找,直到根节点。
js
function unwindUnitOfWork(unitOfWork) {
let incompleteWork = unitOfWork;
do {
// 是否为Suspense?不是的话返回null
const next = unwindWork(incompleteWork);
// 如果找到了,将此节点赋值给workInProgress
// workInProgress是作为render阶段下一次执行的节点
if (next !== null) {
workInProgress = next;
return;
}
// 没找到,继续向上查找
const returnFiber = incompleteWork.return;
// 清除副作用,deletions为待删除的节点集合
if (returnFiber !== null) {
returnFiber.deletions = null;
}
incompleteWork = returnFiber;
} while (incompleteWork !== null);
// 没有 边界 中止unwind流程,一直到root
workInProgress = null;
}
如果找到最近的Suspense节点,首先也要从栈中移除,因为unwind流程同样是回溯流程。最后为Suspense节点标记DidCapture。
js
export function unwindWork(wip) {
const flags = wip.flags;
switch (wip.tag) {
case SuspenseComponent:
// 移除当前Suspense节点
popSuspenseHandler();
if (
(flags & ShouldCapture) !== NoFlags &&
(flags & DidCapture) === NoFlags
) {
// 更改标记
// 此逻辑相当于先删除ShouldCapture标记,然后赋值为DidCapture标记
wip.flags = (flags & ~ShouldCapture) | DidCapture;
return wip;
}
return null;
}
}
在执行完整个unwind流程后,重新进入workLoop函数,此时workInProgress已经被重新赋值为了刚刚找到的这个Suspense节点。所以进入beginwork时,所要处理的fiber节点正是这个Suspense,此时Suspense节点已经被标记了
DidCapture,代表需要进入挂起流程,创建挂起流程的中间状态节点。此时渲染在页面中的是fallback中的内容。
当Promise中的状态确定后,执行触发更新的then函数,此时 react 会重新触发一次更新,再次进行到Suspense节点时,由于上次创建挂起流程后DidCapture标记已经被移除,所以本次更新会创建正常流程的节点,再次进入<Cpn />这个函数组件时,再次执行use()函数,由于此时传入use的Promise已经返回了状态,变为fulfilled状态,所以会在内部直接返回thenable的值,最终显示正常的页面内容。
写在最后 ⛳
未来可能会更新实现mini-react和antd源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳