使用 React.lazy
方法可以进行代码分割,配合 Suspend 可以实现异步加载组件期间显示 loading 效果,组件加载完毕后渲染显示内容的效果,那么 lazy 是如何实现的?与 loadable-components 又有什么不同?
下面是 React.lazy
与 loadable 的使用方式很简单,都需要使用 Suspend
进行包裹,从而使得异步组件加载时可以展现一个占位元素。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 import { lazy, Suspense } from 'react' import { lazy as loadableLazy} from '@loadable/component' const LazyChild = lazy (() => import ('./Child' ));const LoadableChild = loadableLazy (() => import ('./Child' ))function App ( ) { return ( <> <div className ='lazy' > <Suspense fallback ={ <div > loading...</div > }> <LazyChild /> </Suspense > </div > <div className ='loadable' > <Suspense fallback ={ <div > loading...</div > }> <LoadableChild /> </Suspense > </div > </> ) } export default App
如果想要知道 React.lazy
的工作方法, 我们需要先知道 Suspend
是如何执行的。
在 React 中,Suspense
是一种用于处理异步操作的机制,特别是数据加载或代码拆分(code splitting)。它做的事情其实说起来很简单,就是判断自己的子元素中是否有处于 suspend 状态的,如果有,则渲染占位元素,待子组件全都不为 suspend 状态后,再渲染整个子组件树。那么问题的关键就在于 React 内部是如何判断子组件处在 suspend 状态的 ?
当一个组件被包裹在 Suspense
中时,React 内部会通过以下几个步骤判断子组件是否处于 suspense
状态:
抛出 Promise : 当 React 渲染一个组件时,如果组件内部有异步操作(例如数据加载),这些操作通常会返回一个 Promise。如果这个 Promise 还没有解决(即处于 pending 状态),需要将这个组件抛出去。(抛出去?没错,就是使用 throw
方法)
捕获 Promise : 在组件渲染过程中,如果某个组件抛出了一个未解决的 Promise
,React 会识别到这是一个异步操作,并且当前渲染无法完成。
Suspense 边界 : React 会向上查找最近的 Suspense
边界。Suspense
边界是指被 Suspense
组件包裹的部分。当找到最近的 Suspense
边界后,React 会暂停该边界内的渲染,并显示 fallback
(备用内容)。
触发重新渲染 : 当捕获到 Promise 解决(resolved)或拒绝(rejected)时,Promise 会触发状态更新,React 会重新尝试渲染被暂停的组件。
在上面的例子中,Child
是一个异步加载的组件。当 Child
被加载时,lazy 方法需要 throw 一个 Promise
,React 会捕获到这个异步操作,并在加载完成之前显示 Suspense
组件的 fallback
内容(即 Loading...
)。
React.lazy
下面我们来看下 React.lazy
的具体实现。React.lazy
的代码在 React 源码的 packages/react/src/ReactLazy.js 文件中。我们一拉到底先看这个文件导出的 lazy 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export function lazy<T>( ctor : () => Thenable <{default : T, ...}>, ): LazyComponent <T, Payload <T>> { const payload : Payload <T> = { _status : Uninitialized , _result : ctor, }; const lazyType : LazyComponent <T, Payload <T>> = { $$typeof : REACT_LAZY_TYPE , _payload : payload, _init : lazyInitializer, }; return lazyType; }
这里 lazy 函数接收一个返回值为 Promise 的函数,返回一个 LazyComponent
,LazyComponent
的定义也在这个文件中,它其实是一个简单的对象:
1 2 3 4 5 6 export type LazyComponent <T, P> = { $$typeof : symbol | number , _payload : P, _init : (payload: P ) => T, _debugInfo?: null | ReactDebugInfo , };
这里 $$typeof
用来标记当前组件类型为 LazyComponent
,_payload
是当前组件的 state,用来保存组件的初始化状态与异步组件加载函数。
然后就是 _init
方法,React 内部判断当前组件为 LazyComponent
后会调用该方法初始化组件。我们可以看到这个方法对应了文件中的 lazyInitializer
函数(为了简单我只保留了该函数第一次被调用时的理想情况,因为整个过程是异步的,因此省略了很多边界判断。):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 function lazyInitializer<T>(payload : Payload <T>): T { const ctor = payload._result ; const thenable = ctor (); thenable.then ( moduleObject => { const resolved : ResolvedPayload <T> = (payload : any ); resolved._status = Resolved ; resolved._result = moduleObject; }, error => { }, ); const pending : PendingPayload = (payload : any ); pending._status = Pending ; pending._result = thenable; throw payload._result ; }
在该函数第一次被调用时,会调用异步组件加载函数,并修改组件的状态。最后在异步组件还未加载完毕时,将异步函数加载组件返回的 Promise 抛出。
这个 throw 出的 Promise 会被上层的 Suspend 拦截,进入 fiber 的调度过程。等 Promise 完成时,触发重渲染,移除占位组件,渲染子组件。
loadable-components (Loadable Components)[https://loadable-components.com/ ] 是一个第三方库,它也提供了类似 lazy 的方法(见上面的例子)。这里我们看她的另一个方法 loadable
,使用方法如下:
1 2 3 4 5 6 7 8 9 import loadable from '@loadable/component' const OtherComponent = loadable (() => import ('./OtherComponent' ))function MyComponent ( ) { return ( <div > <OtherComponent /> </div > ) }
注意这里 loadable 是不需要跟 Suspend
一起使用的,我们可以直接使用 fallback 参数来指定一个占位元素:
1 2 3 4 5 6 7 8 9 import loadable from '@loadable/component' const OtherComponent = loadable (() => import ('./OtherComponent' ))function MyComponent ( ) { return ( <div > <OtherComponent fallback ={ <div > Loading...</div > } /> </div > ) }
那么 loadable
该如何实现呢?其实比 React.lazy
更加简单。只需要在渲染时调用异步组件加载函数获取到 Promise,在 Promise 返回前,渲染占位元素。等待 Promise 返回后,触发状态变更,渲染异步组件即可。
我们打开 loadable
的实现:packages/component/src/createLoadable.js ,可以看到 loadable 方法同样也返回了一个组件也就是其中的 InnerLoadable
,我们首先来看这个组件的 render 函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 render ( ) { const { forwardedRef, fallback : propFallback, __chunkExtractor, ...props } = this .props const { error, loading, result } = this .state if (error) { throw error } const fallback = propFallback || options.fallback || null if (loading) { return fallback } return render ({ fallback, result, options, props : { ...props, ref : forwardedRef }, }) }
可以看到这里如果组件处在 loading 状态,那么 render 函数就会返回 fallback 元素,否则调用外层的 render 方法,其内部会渲染加载好的异步组件。所关键就在两个事情:
调用异步组件加载函数。
修改 loading 变量。
在哪里调用异步组件加载函数呢?由于这是一个类组件,因此 loadable
将其放在了 componentDidMount
钩子函数中:
1 2 3 4 5 6 componentDidMount ( ) { this .mounted = true if (this .state .loading ) { this .loadAsync () } }
loadAsync
方法内部会调用异步组件加载函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 loadAsync ( ) { const promise = this .resolveAsync () promise .then (loadedModule => { const result = resolve (loadedModule, this .props , Loadable ) this .safeSetState ( { result, loading : false , }, () => this .triggerOnLoad (), ) }) .catch (error => this .safeSetState ({ error, loading : false })) return promise }
同时我们可以看到在 promise resolve 后,直接将 loading 状态改为了 false 触发了组件的更新,进入到渲染子组件的部分,大功告成。
这里我们还可以有一个极简版的实现,看起来更直观一些:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import React , { useState, useEffect } from 'react' ;const loadComponent = ( ) => { return new Promise ((resolve ) => { setTimeout (() => { resolve (() => <div > 异步加载的组件</div > ); }, 2000 ); }); }; const AsyncComponentLoader = ( ) => { const [loading, setLoading] = useState (true ); const [Component , setComponent] = useState (null ); useEffect (() => { let isMounted = true ; loadComponent ().then ((LoadedComponent ) => { if (isMounted) { setComponent (() => LoadedComponent ); setLoading (false ); } }); return () => { isMounted = false ; }; }, []); if (loading) { return <div > 加载中...</div > ; } return <Component /> ; };
性能? 可以看到 loadable components
的方法简单直接,没有用到 Suspend
内的调度,他们的性能会有差别吗?我们可以通过以下代码来测试一下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import { lazy, Suspense } from 'react' import loadable from '@loadable/component' const LazyChild = lazy (() => import ('./Child' ));const LoadableChild = loadable (() => import ('./Child' ))function App ( ) { return ( <> <div className ='lazy' > <h1 > lazy</h1 > <Suspense fallback ={ <div > loading...</div > }> <LazyChild /> </Suspense > </div > <div className ='loadable' > <h1 > loadable</h1 > <LoadableChild fallback ={ <div > loading...</div > }/> </div > </> ) } export default App export default function Child ( ) { return <div > 123123</div > }
差距很细微,但多次渲染都可以发现 loadable 的优先级要高一些,当然这个差距可以忽略不记了,lazy 由于官方支持,有更多的功能和组合,还是更推荐用的。