Suspense的介绍和原理
系列文章:
- React实现系列一 - jsx
- 剖析React系列二-reconciler
- 剖析React系列三-打标记
- 剖析React系列四-commit
- 剖析React系列五-update流程
- 剖析React系列六-dispatch update流程
- 剖析React系列七-事件系统
- 剖析React系列八-同级节点diff
- 剖析React系列九-Fragment的部分实现逻辑
- 剖析React系列十- 调度<合并更新、优先级>
- 剖析React系列十一- useEffect的实现原理
- 剖析React系列十二-调度器的实现
- 剖析React系列十三-react调度
- useTransition的实现
- useRef的实现
Suspense介绍
suspense是React 16.6
新出的一个功能,用于异步加载组件,可以让组件在等待异步加载的时候,渲染一些fallback的内容,让用户有更好的体验。
这一章节可以让我们了解基本的Suspense
的实现原理。
Suspense的基本使用
1. 异步加载组件
下面我们使用lazy
加载OtherComponent
组件,当组件数据加载完成后,渲染OtherComponent
组件,如果加载过程中,我们可以渲染Loading...
的内容。
js
const OtherComponent = React.lazy(() => import('./OtherComponent'));
function App() {
return (
<div>
<Suspense fallback={<div>Loading...</div>}>
<OtherComponent />
</Suspense>
</div>
);
}
由于OtherComponent
组件中,数据需要异步加载,所以在数据加载完成之前,我们需要一个界面去表示正在加载数据的过程,这个界面就是fallback
。
Suspense的原理
在了解实现原理之前,首先来看看,我们需要实现的例子内容。
正常情况下,React
会有很多场景使用到Suspense
,我们不可能全部都实现。比如:
Razy
组件懒加载transition fallback
等use
的hook
在新版的React
文档中的一些demo
是通过use
hook实现的,由于我们之前已经实现了hook
的逻辑部分。所以我们就实现一下如下demo的原理。
javascript
function App() {
const [num, setNum] = useState(0);
return (
<div>
<button onClick={() => setNum(num + 1)}>change id: {num}</button>
<Suspense fallback={<div>loading...</div>}>
<Cpn id={num} timeout={2000} />
</Suspense>
</div>
);
}
export function Cpn({ id, timeout }) {
const [num, updateNum] = useState(0);
const { data } = use(fetchData(id, timeout));
if (num !== 0 && num % 5 === 0) {
cachePool[id] = null;
}
return (
<ul onClick={() => updateNum(num + 1)}>
<li>ID: {id}</li>
<li>随机数: {data}</li>
<li>状态: {num}</li>
</ul>
);
}
这里列举了App
和Cpn
2个部分内容,我们要实现子组件使用use
包裹一个Promise
的请求,然后被挂起,展示fallback
的内容,等待请求完成后,再渲染子组件。
整体流程
在深入细节之前,我们先了解一下整个suspense
的执行流程。
beginWork
是进入上述4种情况的任意一种completeWork
是对比current Offscreen mode
和wip Offscreen mode
,如果发现下面的情况,就需要标记Visibility effectTag
, 用于commit的隐藏和显示操作:mode
从hidden
变为visible
mode
从visible
变成mode
current === null &&
hidden`
commitWork
时处理visibility effectTag
上面是一个整体的流程说明。 如果回到我们要分析的例子,它的大致流程是这样的:
- 首先进入正常的
render
流程(默认首次进入Cpn
渲染) - 遇到
use
执行,抛出错误,unwind
进入suspense
节点,进入挂起状态 - 进入挂起流程对应的
render
- 进入挂起流程对应的
commit
阶段(渲染loading
) - 请求返回后,进入正常流程对应的
render
阶段 - 进入正常流程对应
commit
阶段(渲染Cpn
)
suspense的细节
在我们平时使用suspense
的时候,有一些细节方面,不晓得大家有没有注意到。
- 首次渲染的时候,
React
在请求回到之前是直接渲染fallback
中的内容。并不会渲染子组件的内容。下面一段话,来自官方文档,在请求返回之前,子组件不会有任何状态被保存。
React does not preserve any state for renders that got suspended before they were able to mount for the first time. When the component has loaded, React will retry rendering the suspended tree from scratch.
- 渲染子组件后,如果我们再次
loading
中,渲染后的子组件并不会被销毁,而是通过css
样式隐藏。 - 当子组件再次渲染的时候,它原来的状态是可以保留的。比如上面的例子,我们点击
change id
按钮,虽然重新渲染了,但是子组件的num
状态是保留的, 一直都是数字8。
如果我们按照正常的一个Child
组件来实现的话。在挂起的时候渲染fallback
, 在正常的时候渲染Cpn
组件。这样的话,不能满足我们的第二、第三点。
它不能够保存状态,所以不适合我们的Suspense
实现。
suspense的fiber结构
为了保留相应的状态,实现css
控制隐藏元素,我们肯定是需要把fallback
和子组件cpn
都渲染到fiber
树上的。这样我们就可以通过css
样式来控制隐藏和显示。
同时也可以保存状态。所以如果只有一个Child-fiber
是肯定不行的。
react
中是如下的结构来标识suspense
的fiber tree
。
新增了一种类型Offscreen
, 用于标识真正的子元素的显示和隐藏。
整体流程类似这样
- 初始化渲染的时候,由于
children
组件的数据没有返回,所以Offscreen
将mode
设置为hidden
- 通过
sibling
渲染Fragment
的结构。当数据返回后,再次开始调度,重新渲染Offscreen
。 - 如果发生子组件更新渲染行为,会再次走挂起流程,等待数据返回后,再次更新内容。
所以在beginWork
开始调和的时候,我们需要分4种情况进行处理:
-
初始化(
mount
)- 挂起流程
mountSuspenseFallbackChildren
- 正常流程
mountSuspensePrimaryChildren
- 挂起流程
-
更新(
update
)- 挂起流程
updateSuspenseFallbackChildren
- 正常流程
updateSuspensePrimaryChildren
- 挂起流程
beginWork流程
从上面的fiber
结构图来看,在调和阶段,针对SuspenseComponent
的类型,我们需要区分mount
和update
的情况。
javascript
function beginWork = (wip: FiberNode, renderLane: Lane) => {
// 针对suspense的处理
case SuspenseComponent:
return updateSuspenseComponent(wip);
}
主要逻辑都在updateSuspenseComponent
中,我们来分析一下它的具体内容。
- 整体是做一个整合,根据
wip.alternate
来判断是否为mount
或者update
- 根据
showFallback
判断是否需要展示fragment -> fallback
的分支
javascript
function updateSuspenseComponent(wip: FiberNode) {
const current = wip.alternate;
const nextProps = wip.pendingProps;
let showFallback = false; // 是否显示fallback
const didSuspend = (wip.flags & DidCapture) !== NoFlags; // 是否挂起
if (didSuspend) {
// 显示fallback
showFallback = true;
wip.flags &= ~DidCapture; // 清除DidCapture
}
const nextPrimaryChildren = nextProps.children; // 主渲染的内容
const nextFallbackChildren = nextProps.fallback;
pushSuspenseHandler(wip);
if (current === null) {
// mount
if (showFallback) {
// 挂起
return mountSuspenseFallbackChildren(
wip,
nextPrimaryChildren,
nextFallbackChildren
);
} else {
// 正常
return mountSuspensePrimaryChildren(wip, nextPrimaryChildren);
}
} else {
// update
if (showFallback) {
// 挂起
return updateSuspenseFallbackChildren(
wip,
nextPrimaryChildren,
nextFallbackChildren
);
} else {
// 正常
return updateSuspensePrimaryChildren(wip, nextPrimaryChildren);
}
}
}
首次进入mountSuspensePrimaryChildren
在开始beginWork
到达suspense
的时候,首次进入的时候showFallback = false
。我们会进入正常的调和流程mountSuspensePrimaryChildren
。
我们看看具体的mountSuspensePrimaryChildren
的执行。
javascript
/**
* 正常流程的mount阶段
* @param wip
* @param primaryChildren
*/
function mountSuspensePrimaryChildren(wip: FiberNode, primaryChildren: any) {
const primaryChildProps: OffscreenProps = {
mode: "visible",
children: primaryChildren,
};
const primaryChildFragment = createFiberFromOffscreen(primaryChildProps);
wip.child = primaryChildFragment;
primaryChildFragment.return = wip;
return primaryChildFragment;
}
- 将真正需要渲染的
子组件(cpn) primaryChildren
作为offscreen fiber
的子fiber
。同时赋值mode = visible
- 需要创建一个
offscreen
对应的fiber
。 并将suspense.child
指向创建的offscreen
然后我们继续调和到offscreen
。updateOffscreenComponent
就是常规的调和子ReactElement
, 当执行到primaryChildren(cpn)
的时候,进入函数组件(cpn
)的执行。
当执行到hook use
的时候,我们来分析一下如何运行的
函数组件的use
的hook
use
接受1个参数,类型为Thenable
或者ReactContext
。我们主要是探讨当接收到一个promise
的时候,内部应该是怎么处理。
javascript
/**
* use hook (接受promise / context)
*/
function use<T>(usable: Usable<T>): T {
if (usable !== null && typeof usable === "object") {
if (typeof (usable as Thenable<T>).then === "function") {
// thenable
const thenable = usable as Thenable<T>;
return trackUsedThenable(thenable);
} else if ((usable as ReactContext<T>).$$typeof === REACT_CONTEXT_TYPE) {
// context
const context = usable as ReactContext<T>;
return readContext(context);
}
}
throw new Error("不支持的use参数");
}
如果是一个promise
,会执行trackUsedThenable
的逻辑。
trackUsedThenable
主要是用于包装接受的promise
。
- 新增一个状态
status
(包含pending
、rejected
,fulfilled
) - 添加请求返回后的,
then
的逻辑,等promise
处理后,变更status
的值。 - 将包装后的promise(
thenable
)进行包装,提供全局获取的方式(赋值suspendedThenable
) - 抛出一个错误,中断当前的
beginWork
的调和流程
javascript
export function trackUsedThenable<T>(thenable: Thenable<T>) {
switch (thenable.status) {
case "fulfilled":
return thenable.value;
case "rejected":
throw thenable.reason;
default:
if (typeof thenable.status === "string") {
thenable.then(noop, noop);
} else {
// untracked
//pending
const pending = thenable as unknown as PendingThenable<T, void, any>;
pending.status = "pending";
pending.then(
(val) => {
if (pending.status === "pending") {
// @ts-ignore
const fulfilled: FulfilledThenable<T, void, any> = pending;
fulfilled.status = "fulfilled";
fulfilled.value = val;
}
},
(err) => {
// @ts-ignore
const rejected: RejectedThenable<T, void, any> = pending;
rejected.status = "rejected";
rejected.reason = err;
}
);
}
break;
}
suspendedThenable = thenable;
throw SuspenseException;
}
这里我们抛出了一个错误,去中断当前的调和过程。
处理抛出的错误
在之前的章节中,我们讲过workLoop
文件的renderRoot
递归中,有try catch
的错误处理。
javascript
/**
* 并发和同步更新的入口(render阶段)
* @param root
* @param lane
* @param shouldTimeSlice
*/
function renderRoot(root: FiberRootNode, lane: Lane, shouldTimeSlice: boolean) {
do {
try {
if (wipSuspendedReason !== NotSuspended && workInProgress !== null) {
// 有错误,进入unwind流程
const throwValue = wipThrowValue;
wipSuspendedReason = NotSuspended;
wipThrowValue = null;
// unwind操作
throwAndUnwindWorkLoop(root, workInProgress, throwValue, lane);
}
shouldTimeSlice ? workLoopConcurrent() : workLoopSync();
break;
} catch (e) {
handleThrow(root, e);
}
} while (true);
}
所以会进入handleThrow
的错误处理流程。
javascript
function handleThrow(root: FiberRootNode, throwValue: any) {
// 是suspense抛出的错误
if (throwValue === SuspenseException) {
throwValue = getSuspenseThenable();
wipSuspendedReason = SuspendedOnData;
}
wipThrowValue = throwValue;
}
- 标记
wipSuspendedReason
的原因是suspense
- 获取
trackUsedThenable
中包裹的suspendedThenable
,并将其赋值给wipThrowValue
处理后,会继续执行renderRoor
的递归流程。由于wipSuspendedReason
的赋值,所以会执行到throwAndUnwindWorkLoop
函数。
throwAndUnwindWorkLoop
流程的处理
throwAndUnwindWorkLoop
它接受根节点、当前的fiber、包装后的thenable
、优先级lane
。
javascript
/**
* unWind流程的具体操作
* @param root
* @param unitOfWork 当前的fiberNode(抛出错误的位置)
* @param thrownValue 请求的promise
* @param lane
*/
function throwAndUnwindWorkLoop(
root: FiberRootNode,
unitOfWork: FiberNode,
thrownValue: any,
lane: Lane
) {
// 重置FC 的全局变量
resetHookOnWind();
// 请求返回后重新触发更新
throwException(root, thrownValue, lane);
// unwind
unwindUnitOfWork(unitOfWork);
}
它主要是分了三个函数分别处理以下内容:
- 由于中断了渲染,需要重置一些函数组件的变动。
- 请求返回后重新触发更新
unwind
流程
请求返回后触发更新
思考我们在use
的请求返回后,我们肯定是需要重新开始调度的。
类似于信号一样,它请求返回后,需要ping
下告诉我,我应该开始新的调度,用于渲染返回后的数据,展示给用户真正的内容。
throwException
函数的主要逻辑就是进行相应的逻辑处理。但是还有一些额外的缓存处理,比如重复的请求只需要ping
一次,这个不是我们关心的重点
javascript
export function throwException(root: FiberRootNode, value: any, lane: Lane) {
// Error Boundary
// thenable
if (
value !== null &&
typeof value === "object" &&
typeof value.then === "function"
) {
const wakeable: Wakeable<any> = value;
const suspenseBoundary = getSuspenseHandler();
if (suspenseBoundary) {
suspenseBoundary.flags |= ShouldCapture;
}
attachPingListener(root, wakeable, lane);
}
}
同时需要获取最近的suspense fiber
。同时将它标记为ShouldCapture
(render
阶段,捕获到一些东西(Error Bound / 抛出的挂载的数据)),ShouldCapture
标记会在unwind
中用于找到对应的suspense fiber
attachPingListener
的主要逻辑是用于请求返回后,开始新的调度。
我简化后的代码如下:
javascript
function attachPingListener(
root: FiberRootNode,
wakeable: Wakeable<any>,
lane: Lane
) {
function ping() {
// fiberRootNode
markRootPinged(root, lane);
markRootUpdated(root, lane);
ensureRootIsScheduled(root); // 开启新的调度
}
wakeable.then(ping, ping);
}
}
unwind
流程
在之前我们已经学习到了2种的流程,现在多了一种unwind
流程。
beginWork
:往下深度优先遍历 (completeWork
:往上深度优先遍历(到rootfiber
)unwind
:往上遍历祖辈
涉及到unwind
流程主要是有2种:
Suspense
Error Boundary
我们现在只讨论suspense
, 它主要是执行use
的fiber
(抛出错误的fiberNode
位置)向上递归到最近的suspense fiber
。
javascript
/**
* 一直向上查找,找到距离它最近的Suspense fiberNode
* @param unitOfWork
*/
function unwindUnitOfWork(unitOfWork: FiberNode) {
let incompleteWork: FiberNode | null = unitOfWork;
// 查找最近的suspense
do {
const next = unwindWork(incompleteWork);
if (next !== null) {
workInProgress = next;
return;
}
const returnFiber = incompleteWork.return as FiberNode;
if (returnFiber !== null) {
returnFiber.deletions = null;
}
incompleteWork = returnFiber;
} while (incompleteWork !== null);
// 使用了use 但是没有定义suspense -> 到了root
wipRootExitStatus = RootDidNotComplete;
workInProgress = null;
}
php
/**
* unwind的每一个fiberNode 的具体操作
* @param wip
*/
export function unwindWork(wip: FiberNode) {
const flags = wip.flags;
switch (wip.tag) {
case SuspenseComponent:
popSuspenseHandler();
if (
(flags & ShouldCapture) !== NoFlags &&
(flags & DidCapture) === NoFlags
) {
// 找到了距离我们最近的suspense
wip.flags = (flags & ~ShouldCapture) | DidCapture; // 移除ShouldCapture、 添加DidCapture
return wip;
}
return null;
break;
case ContextProvider:
const context = wip.type._context;
popProvider(context);
return null;
default:
return null;
}
}
经过unwind
的流程后,将workInProgress = suspense fiber
, 这样继续执行renderRoot
的时候,会回溯到suspense fiber
再次进行调和。
同时我们将当前的suspense fiber.flags
的ShouldCapture
的改成DidCapture
, 确定是需要进行了回溯unwind
流程
小结
经过一系列的操作后,我们又开始从suspense fiber
开始beginWork
进行调和。但是有一些flag
的变化
- 新增了
DidCapture
,标记是因为unwind
回到的suspense fiber
重新调和suspense
再次执行updateSuspenseComponent
的情况下,由于DidCapture
的存在,所以会执行到showFallback = true
,然后执行我们渲染fallback
的逻辑。mountSuspenseFallbackChildren
渲染fallbak的执行
mountSuspenseFallbackChildren
的逻辑主要是如下:
- 生成
fragment -> fallback
的fiber
。并将offscreen
的sibling
指向fragment
。 - 重点 是返回了
fallbackChildFragment
, 但是suspense.child
指向primaryChildFragment
(子元素)
javascript
/**
* 挂起状态的mount阶段
* @param wip
* @param primaryChildren
* @param fallbackChildren
*/
function mountSuspenseFallbackChildren(
wip: FiberNode,
primaryChildren: any,
fallbackChildren: any
) {
const primaryChildProps: OffscreenProps = {
mode: "hidden",
children: primaryChildren,
};
const primaryChildFragment = createFiberFromOffscreen(primaryChildProps);
const fallbackChildFragment = createFiberFromFragment(fallbackChildren, null);
primaryChildFragment.return = wip;
fallbackChildFragment.return = wip;
primaryChildFragment.sibling = fallbackChildFragment;
wip.child = primaryChildFragment;
return fallbackChildFragment;
}
大致的fiber
结构如下图:
在mount
的时候由于离屏Dom
, 不需要我们标记Placement
, 也会被插入到页面中渲染。
这样继续调和后,达到completeWork -> commit
之后就会渲染fallback
的内容到界面之中。
至此,在数据没有返回的时候,我们的页面渲染就完成了。
由于篇幅过长,等数据请求回来后,当我们监听到数据返回重新渲染的逻辑,我们下一篇文章介绍,。