本系列会实现一个简单的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
阶段结束后,会进入commi
t阶段,该阶段不可中断,主要是去依据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
变为visible
mode
从visible
变为hidden
current === 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
源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳