一、前面的话
在react中为我们提供了一个非常有用的组件,那就是<Suspense/>
,他可以包裹一个异步组件,当这个异步组件处于pending
状态的时候会展示一个过渡的UI,当异步组件处于resolved
状态的时候会显示真正的UI,我们来看一下如何使用Suspense
和 react提供的lazy
结合起来达到异步加载状态的目的
JSX
import { lazy , Suspense } from 'react';
const LazyComponent = React.lazy(() => import('./xxx'));
export default function App() {
return (
<Suspense fallback={<span>loading...</span>}>
<LazyComponent/>
</Suspense>
)
}
它的效果如下:
接下来我们就来一步一步看一下这究竟是怎么做到这一点的!
二、lazy懒加载组件
要先从lazy
这个api开始说起,根据上面的内容,LazyComponent
是由lazy
这个调用返回的结果,它能够被直接渲染,在没有Suspense加持的情况下,也是可以异步渲染出组件的,如下所示
js
const LazyComponent = React.lazy(() => import('./LazyComponent.js'));
const FunctionComponent = () => {
const [count, setCount] = React.useState(1);
const onClick = () => {
setCount(count + 1);
};
return (
<div>
<button onClick={onClick}>{ count }</button>
<LazyComponent/>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<FunctionComponent />);
效果如下:
我们看一下lazy
的实现原理
js
function lazy(ctor) {
var payload = { // 创建一个payload
_status: Uninitialized, // -1
_result: ctor, // ctor 就是用户传递的哪个()=> import("xxxxx") 实际上等价于 ()=> Promise<any>
};
var lazyType = { // 这是一个REACT_LAZY_TYPE类型的ReactElement
$$typeof: REACT_LAZY_TYPE,
_payload: payload,
_init: lazyInitializer, // 下面分析一下lazyInitializer
};
// 下面是给lazyType做属性的配置,不重要了解即可
{
Object.defineProperties(lazyType, {
defaultProps: {
configurable: true,
get: function () {...},
set: function (newDefaultProps) { ...},
},
propTypes: {
configurable: true,
get: function () {... },
set: function (newPropTypes) {...}
}
});
}
return lazyType;
}
根据我提供的注释我们可以看到,其实lazy就是返回了一个REACT_LAZY_TYPE
类型的ReactElement节点,并且用一个状态机记录了当前的这个节点处于什么样的状态,引用者传进来的函数引用
这里要重点分析一下()=> import('xxxx')
,import('xxx')
是ES6提供的一种异步加载模块的方式,他会返回一个Promise
,因此可以使用.then
获取异步加载所得到的数据
接下来我们看一下lazyInitializer
的实现
js
function lazyInitializer(payload) {
if (payload._status === Uninitialized) { // 如果是初始化状态
var ctor = payload._result; // ()=> import('xxx')
var thenable = ctor(); // 得到一个Promise
thenable.then( // 调用.then
function (moduleObject) {
if (
payload._status === Pending ||
payload._status === Uninitialized
) { // 标记成功
var resolved = payload;
resolved._status = Resolved;
resolved._result = moduleObject;
}
},
function (error) {
if ( // 标记失败
payload._status === Pending ||
payload._status === Uninitialized
) {
var rejected = payload;
rejected._status = Rejected;
rejected._result = error;
}
}
);
if (payload._status === Uninitialized) {// 如果是初始化
var pending = payload;
pending._status = Pending; // 标记正在进行
pending._result = thenable;
}
}
if (payload._status === Resolved) { // 如果不是初始化
var moduleObject = payload._result;
if (moduleObject === undefined) {
报错
}
if (!("default" in moduleObject)) {
报错
}
return moduleObject.default;
} else {
// 初始化都会进入到这里
throw payload._result; // 抛出错误
}
}
经过分析我们会发现lazyInitializer
会根据payload
的状态来采取不同的行为:
-
如果是初始化状态 在这里它会执行用户传进来的函数,得到一个Promise,并且开始调用这个Promise,得到异步的结果,并且标记自己处于
Pedning
状态,然后抛出错误 -
如果
Resolved
的状态那么就判断这个得到的值是否合法,合法就返回给调用者
但不用担心,此时我们分析了这个函数如果执行的话,直到现在用户只是调用了lazy
,这个函数还没到执行的时候,现在用户仅仅只是得到了一个lazy
类型的ReactElement
类型的节点
而真正让这个函数执行得地方还是得在render
阶段,当调和到lazy
类型的节点的时候,会执行mountLazyComponent
js
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes
) {
var props = workInProgress.pendingProps; // lazy的组件一般没有props
var lazyComponent = elementType; // ReactElement
var payload = lazyComponent._payload; // 这就是上面的payload
var init = lazyComponent._init; // 获取那个init函数,就是我们上面分析的那个
var Component = init(payload); // 调用它,第一次会抛出错误
//芭比Q,下面不用看了
...
}
根据我们上面的分析,在调用lazyInitializer
函数的时候,如果是第一次调用,会进入第一种情况,状态还是初始化的状态,因此会执行异步函数,得到一个正在调用的Promise
,然后会调用.then
获取它的结果,然后将其保存在payload
中,然后将状态置为Pending
,最后抛出错误,所以后面的逻辑都不用看了,第一次在这里会抛出错误,阻塞后面的代码,整个render阶段被迫提前结束
如果提前结束了render阶段
,那么后面该如何运行呢?
原来当lazy
类型的render过程中,准确的来说应该是beginWork
中因为第一次执行init
函数导致抛出错误,阻塞了后面的过程,react会提前结束beginWork
环节,然后react会捕获这个错误,还记得那个workLoop
么?它是这样子的:
js
do {
try {
workLoopSync(); // 当这里抛出错误时
break;
} catch (thrownValue) {
handleError(root, thrownValue); // 会来到这里
}
} while (true);
因此实际上react并不会因为抛出了这个错误就完蛋了,甚至这个错误是刻意抛出的,为的就是在handleError
中捕获它,然后做不同的逻辑处理
在handleError
中会基于抛出错误的节点开始提前进入completeWork
,然后将整棵树标记为未完成 的状态,最后因为上层函数拿到这个是否调和完整棵树的状态,决定是否进行commit
流程
结果就是这棵树没有完成,因此不会进行commit
阶段,第一次render
因为lazy
类型组件的存在就这样匆匆结束了
那现相信大家和我有同样的问题,那react是怎么重启render
的呢? ,因为在平常开发中lazy组件也是可以渲染出组件的呀,所以一定有一个重启render的过程才能做到。
原来在handleError
的过程中有一个这样的过程,如果发现了抛出错误的参数是一个Promise
的话,就会认定他是一个懒加载的情况,然后做出重启的操作,正巧我们init
抛出错误的信息刚好是一个Promise
,而重启的操作如下:
js
function throwException(value){// 这个value就是错误信息
...
if (
value !== null &&
typeof value === "object" &&
typeof value.then === "function" // 如果是一个Promise
) {
var wakeable = value; //
var suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // 如有上层有Suspense包裹的话,这里先不谈
if (suspenseBoundary !== null) {
...
} else{
attachPingListener(root, wakeable, rootRenderLanes); // 这里就是关键了,它会监听这个Promise的情况
}
}
那么attachPingListener
发生了什么呢?
js
简化一下就是这样的
var ping = pingSuspendedRoot.bind(null, root, wakeable, lanes);
weakable.then(ping , ping)
看到了吗,如果这个Promise
的状态一旦从Pending
状态变成其他状态,就会执行这个pingSuspendedRoot
,它里面就藏着重新发起调度的ensureRootIsScheduled
逻辑,然后会把更新流程重走一遍,从render
到commit
,最终就呈现出了UI。
这里需要注意的一点就是当重启的这一次render
阶段其实也会遇到lazy
类型的节点,那它还会抛出错误吗?
其实是不会的,因为这一次来到lazy
节点时,执行的init
函数会发现状态已经被修改为Resolved
的状态了, 会直接返回结果,然后返回的结果通常来说是一个组件,就是异步加载的组件,把它作为子组件再继续构建fiber树
js
function mountLazyComponent(
_current,
workInProgress,
elementType,
renderLanes
) {
var props = workInProgress.pendingProps; // lazy的组件一般没有props
var lazyComponent = elementType; // ReactElement
var payload = lazyComponent._payload; // 这就是上面的payload
var init = lazyComponent._init; // 获取那个init函数,就是我们上面分析的那个
var Component = init(payload); // 这一次调用直接获取到值,而不会抛出错误,往下调和异步组件
workInProgress.type = Component;
var resolvedTag = (workInProgress.tag = resolveLazyComponentTag(Component)); // 获取对应的fiber类型
var resolvedProps = resolveDefaultProps(Component, props);
var child;
switch (resolvedTag) {
case FunctionComponent: {
...
child = updateFunctionComponent( // 继续调和
null,
workInProgress,
Component,
resolvedProps,
renderLanes
);
return child;
}
...
}
}
至此lazy类型的组件原理我们就分析完了,它其实利用的是
react
强大的异常捕获机制,以及Promise
灵敏的状态机来实现的,我画个图给大家总结一下
三、Suspense原理
当我们分析了上面的lazy
类型的组件之后Suspense
就很好学习了
Suspense
本质上就是一个ReactElement
类型的对象,没啥好说的;关键要看在render
阶段react如何处理这种类型的fiber组件的,下面一起来看一下
初始化
在初始化时仅仅只是创建了fiber,然后继续调和子组件,由于他的组件就是lazy
类型的组件,因此还是回到上面的逻辑,lazy
组件会抛错啊,因此第一次render
阶段终止了,但是在handleError
处理错误的时候,因为它被Suspense
包裹着,因此逻辑会有不同
js
function throwException(value){ // 这个value就是错误信息
...
if (
value !== null &&
typeof value === "object" &&
typeof value.then === "function" // 如果是一个Promise
) {
var wakeable = value; //
var suspenseBoundary = getNearestSuspenseBoundaryToCapture(returnFiber); // 如有上层有Suspense包裹的话,这里会判定有,实际上就是遍历祖先节点,看是否有Suspense类型的fiber
if (suspenseBoundary !== null) {
suspenseBoundary.flags &= ~ForceClientRender;
markSuspenseBoundaryShouldCapture( // 打标签应该被捕获
suspenseBoundary,
returnFiber,
sourceFiber,
root,
rootRenderLanes
);
attachPingListener(root, wakeable, rootRenderLanes); // 监听重启
} else{
...
}
}
实际上这个逻辑和lazy还是一样的,就是监听Promise
的状态,在Promise
有结果的时候再重启一次render
,这一点是一致的,通过这个机制可以确保当异步组件加载完成后react运行时能够知道在此时更新页面,呈现出最新的UI
但是我们知道从效果上来看,在有Suspense
包裹的时候,在异步组件加载过程中应该会立马展示一个过渡UI,也就是fallback
对应的参数,而需要做到这一点需要发起一次调度啊,也就是说需要经历一个render
+commit
才能做到啊
过渡fiber节点
原来这一切的一切在第一次render的时候就有准备了,在第一次构建fiber树的时候,假设我们的组件是下面这样的
js
<Suspense fallback={...}>
<Lazy/>
</Suspense>
那么实际上在构建fiber
树的时候会有这样的fiber
结构
因此它并不是每个组件对应一个fiber
节点,Suspense
对应的实际上是有2个fiber节点,当我们知道这一点之后,当做了监听完的动作之后,我们再回到外层看一下,会执行一个completeUnitOfWork
的动作,这个动作实际上在上面我们讲到的只有lazy
的情况也会执行,只不过在只有lazy
组件的时候它会一直调和到root
节点,导致workInProgress
为null
,而在有Suspense
会表现的有所不同
因为这是由于出现了异常导致的completeUnitOfWork
,因此不会走正常的completeWork
,而是走unwindWork(current, completedWork);
在unwindWork
向上归并的时候,如果遇到有Suspense
节点的情况会保留这个Suspense
节点的信息,实际上就是不会一直往上走到root节点,而是将workInProgress
指向这个Suspense
的fiber节点,然后就退出completeWork
的流程,然后我们再来看一下render
阶段的引擎函数
js
do {
try {
workLoopSync(); // 这里面需要workInProgress有值才能正常运行
break;
} catch (thrownValue) {
handleError(root, thrownValue);
// 结束后,还是会执行
}
} while (true);
handleError
结束后还会继续接着render,在上面提到的只有lazy组件的情况下,因为workInProgress
不存在所以直接break
退出了render
流程,而在Suspense
组件存在的情况下,会继续从这个Suspense
开始继续render
这一次render
就会直接调和fallback
的内容,这一次根本就不会遇到lazy
类型的组件了,直到整棵fiber树调和完成,然后接着正常进行commit
流程,所以用户看到的就是带有fallback
的UI界面
等到异步组件重新加载完成后,会重新执行一次render
+ commit
构建出含有异步组件的界面
小结: 以上就是Suspense,主要是react在拥有
Suspense
类型的组件的过程中做了处理,使其多了一次默认的render
+commit
的流程,从而使用户能够看到含有过渡状态的UI,我依然用一个图来给大家总结一下
四、最后的话
本篇文章中,我们从源码的角度分析了Suspense
和lazy
组件的原理,并且梳理了它们的流程,接下来我们继续学习react的新特性,吃透react。
后面的文章我们会依然会深入剖析react的源码,学习react的设计思想,如果你也对react相关技术感兴趣请订阅我的《深入理解react》专栏,笔者争取至少月更一篇,我们一起进步,有帮助的话希望朋友点个赞支持下,多谢多谢!