深入理解React中Suspense与lazy的原理
作者:Story
一、前面的话
在react中为我们提供了一个非常有用的组件,那就是<Suspense/>
,他可以包裹一个异步组件,当这个异步组件处于pending
状态的时候会展示一个过渡的UI,当异步组件处于resolved
状态的时候会显示真正的UI,我们来看一下如何使用Suspense
和 react提供的lazy
结合起来达到异步加载状态的目的
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加持的情况下,也是可以异步渲染出组件的,如下所示
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
的实现原理
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
的实现
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
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
么?它是这样子的:
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
,而重启的操作如下:
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
发生了什么呢?
简化一下就是这样的
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树
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
包裹着,因此逻辑会有不同
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树的时候,假设我们的组件是下面这样的
<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
阶段的引擎函数
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,我依然用一个图来给大家总结一下
以上就是深入理解React中Suspense与lazy的原理的详细内容,更多关于React Suspense lazy的资料请关注脚本之家其它相关文章!