源码级解析,搞懂 React 动态加载(下) —— @loadable/component
本系列作为 SPA 单页应用相关技术栈的探索与解析,先从 React 动态加载角度入手,探索市面当前流行的方案的实现原理。
先前我们介绍了 react-loadable 与 React.lazy,后者几乎已经覆盖了所有的使用场景,在 React 18 版本之后也增加了 SSR 支持。今天我们来看一款新的方案 @loadable/component,在动态加载技术已经如此完备的现在,它仍然有不可替代的能力与便捷性。
在上述官网写到,它在支持动态加载的同时,还支持 prefetch,library 分割等特性,同时也有着较为简洁的 API。它不仅支持动态加载组件,还支持 library (模块)的加载,也支持无痛结合 React.Suspense 使用。
@loadable/component 的实现原理与 react-loadable 类似,本文继续通过分析源码实现来帮助大家深刻理解。
改造开头的例子
还记得本系列上篇开头的例子吗?我们用 @loadable/component 改造,它的使用方式与 react-loadable 相似:
import loadable from '@loadable/component'
import Loading from './components/loading';
// fallback 在创建 component 时传入
const MyComponent1 = loadable(() => import('./components/Bar'), {
fallback: <Loading />,
});
// 使用方式: <MyComponent1 />
// fallback 在渲染时传入
const MyComponent2 = loadable(() => import('./components/Bar'));
// 使用方式: <MyComponent2 fallback={<Loading />} />
前端渲染中的 @loadable/component
官方 API 文档提供了非常灵活的且多样的动态加载场景与配置能力,本篇主要聚焦在组件动态加载的实现原理上,暂时忽略一些不利于我们理解主线剧情的细节。对于浏览器纯前端渲染场景来说,本文介绍从两个维度入手:
loadable
直接动态加载组件/模块,可以直接使用,无需配合其他高阶组件:
// 1. 常规 loadable 组件加载
const Hello = loadable(() => import('./Hello'))
// 2. 动态 loadable 组件加载,props 变化可能加载不同的资源地址
const Dynamic = loadable(p => import(`./${p.name}`), {
cacheKey: p => p.name,
})
// 3. library 动态加载,可以动态加载常规模块
const Moment = loadable.lib(() => import('moment'))
function App() {
const [name, setName] = useState(null)
return (
<div>
<button type="button" onClick={() => setName('A')}>Go to A</button>
<button type="button" onClick={() => setName('B')}>Go to B</button>
{name && <Dynamic name={name} />}
<Hello />
<Moment>{({ default: moment }) => moment().format('HH:mm')}</Moment>
</div>
)
}
lazy
类似 React.lazy,将加载中的组件/模块抛出,需要配合 React.Suspense 或者 ErrorBoundary 使用
const Hello = lazy(() => import('./Hello'))
const Moment = lazy.lib(() => import('moment'))
const App = () => (
<div>
<Suspense fallback={<div>Loading...</div>}>
<Hello />
<Moment>{({ default: moment }) => moment().format('HH:mm')}</Moment>
</Suspense>
</div>
)
如果不理解上述几种加载方式的用途与含义,没关系!我们来结合源码来分析实现原理。
动态加载实现原理解析
对于上一小节的第一个例子,我们先来打印一下 Hello 和 Moment 对象,前者是通过 loadable 方法动态引入的组件,后者是 library:
不论是组件还是 library 模块,通过 loadable() 都生成了虚拟 DOM 结构,且为 forwardRef 类型。也许有小伙伴产生了疑问:动态加载组件和 library 究竟有什么区别?为什么要把 library 也封装成组件?
要解答上面的问题,我们先来看一下 loadable 和 loadable.lib 函数的实现。
源码中 loadable.js 处理组件的加载,提供 loadable 和 lazy 两种方式:
源码中 library.js 处理 library 的加载,同样也提供了 loadable 和 lazy 方法:
可以看到,核心都使用了 createLoadable 工厂方法。不同在于组件的动态加载 render 方法直接渲染组件本身;而 library 的动态加载则需要在 children 中提供一个表达式,例如例子中的:
<Moment>{({ default: moment }) => moment().format('HH:mm')}</Moment>
loadable 动态加载了 ‘moment’ 模块,加载完成后获取其默认导出 moment ,然后调用这个方法获取当前时间作为 text 渲染在 DOM 树上。
现在我们可以推测出,所谓的 library 动态加载,就是在 children 中提供一个表达式,将加载完成的模块作为入参处理,并生成 DOM 节点/文字进而渲染。
此外 library 加载也提供了 onLoad 方法,这里我们后面再详细分析。
createLoadable
核心工厂方法 createLoadable 提供了的大致结构如下:
function createLoadable({
defaultResolveComponent = identity,
render,
onLoad,
}) {
function loadable(loadableConstructor, options = {}) {
// 暂时省略,后面分析
}
function lazy(ctor, options) {
return loadable(ctor, { ...options, suspense: true })
}
return { loadable, lazy }
}
lazy 和 loadable 两种加载方式,实际上都在同一个 loadable() 函数中实现:
function loadable(loadableConstructor, options = {}) {
// 根据传入的第一个参数生成 ctor 对象
const ctor = resolveConstructor(loadableConstructor)
const cache = {}
/**
* 如果传入了 cacheKey 选项,返回 props[cacheKey]
* 如果 loadable 的第一个参数传入的是个 object,且有 resolve 方法,则返回 object.resolve(props)
* 如果 loadable 第一个参数传入的是例如:() => import(xxx),则 ctor.resolve(props) => undefined
*/
function getCacheKey(props) {
if (options.cacheKey) {
return options.cacheKey(props)
}
if (ctor.resolve) {
return ctor.resolve(props)
}
return 'static'
}
/**
* 优先使用 options.resolveComponent 解析并返回,否则返回 module
*/
function resolve(module, props, Loadable) {
const Component = options.resolveComponent
? options.resolveComponent(module, props)
: defaultResolveComponent(module)
// 省略逻辑 ...
return Component
}
/**
* 调用传入的加载组件函数 () => import(xxx)
* 缓存结果,然后增加加载状态标识
*/
const cachedLoad = props => {
const cacheKey = getCacheKey(props)
let promise = cache[cacheKey]
// 没加载过,或者加载了但是失败了
if (!promise || promise.status === STATUS_REJECTED) {
promise = ctor.requireAsync(props)
promise.status = STATUS_PENDING
cache[cacheKey] = promise
promise.then(() => promise.status = STATUS_RESOLVED, () => promise.status = STATUS_REJECTED)
}
return promise
}
class InnerLoadable extends React.Component {
// 重点!!接下来分析
}
// 给 InnerLoadable 组件包一个 Context。在 SSR 场景下会注入 __chunkExtractor 属性
const EnhancedInnerLoadable = withChunkExtractor(InnerLoadable);
const Loadable = React.forwardRef((props, ref) => (
<EnhancedInnerLoadable forwardedRef={ref} {...props} />
))
Loadable.displayName = 'Loadable'
Loadable.preload = props => {
Loadable.load(props)
}
Loadable.load = props => {
return cachedLoad(props)
}
return Loadable
}
可以看到,函数的主要逻辑就是生成了一个高阶组件 Loadable,封装了 InnerLoadable 组件。所有动态加载的核心逻辑都在其中,让我们来仔细看看 InnerLoadable 究竟做了些什么:
class InnerLoadable extends React.Component {
constructor(props) {
super(props)
this.state = {
result: null,
error: null,
loading: true,
cacheKey: getCacheKey(props),
}
// SSR 专用逻辑,后面分析...
}
componentDidMount() {
// 从缓存中获取正在加载的模块 promise
const cachedPromise = this.getCache()
// 如果 promise 存在且加载失败,则清掉缓存,下次重新加载
if (cachedPromise && cachedPromise.status === STATUS_REJECTED) {
this.setCache()
}
// 未加载完成,走加载逻辑
if (this.state.loading) {
this.loadAsync()
}
}
componentDidUpdate(prevProps, prevState) {
// cacheKey 变化了,就重新加载动态组件
// 处理 loadable(p => import(`./${p.name}`), { cacheKey: p => p.name })
if (prevState.cacheKey !== this.state.cacheKey) {
this.loadAsync()
}
}
loadAsync() {
// 下面着重介绍...
}
// 省略部分根据 props 获取缓存逻辑 ...
// 省略一些 SSR 专用方法 ...
triggerOnLoad() {
if (onLoad) {
setTimeout(() => {
onLoad(this.state.result, this.props)
})
}
}
render() {
const {
forwardedRef,
fallback: propFallback,
__chunkExtractor,
...props
} = this.props
const { error, loading, result } = this.state
// lazy 场景,把 加载的 promise throw 出去,给外层 Suspense 用
if (options.suspense) {
const cachedPromise = this.getCache() || this.loadAsync()
if (cachedPromise.status === STATUS_PENDING) {
throw this.loadAsync()
}
}
if (error) throw error
// 优先 props.fallback > options.fallback
const fallback = propFallback || options.fallback || null
if (loading) return fallback
return render({
fallback,
result,
options,
props: { ...props, ref: forwardedRef },
})
}
}
上面加载模块的 loadAsync 方法具体实现大致为:
loadAsync() {
const promise = this.resolveAsync()
promise
.then(loadedModule => {
const result = resolve(loadedModule, this.props, Loadable)
this.safeSetState(
{
result,
loading: false,
},
// setState 完成后,调用 onLoad
() => this.triggerOnLoad(),
)
})
.catch(error => this.safeSetState({ error, loading: false }))
return promise
}
/**
* 异步 resolve promise,给 promise 加状态,并非加载的逻辑
*/
resolveAsync() {
const { __chunkExtractor, forwardedRef, ...props } = this.props
return cachedLoad(props)
}
在浏览器端的整体流程和 react-loadable 相似:在首次渲染后加载动态模块,此时展示 fallback 组件,加载完成后更新 state 重新渲染,展示加载完毕后的组件。
loadable 和 lazy 的实现区别
看到这里,我们也就理解了 lazy 和 loadable 两种方式的主要区别:
而上面代码中,会将动态加载的 promise 抛出:
if (options.suspense) {
const cachedPromise = this.getCache() || this.loadAsync()
if (cachedPromise.status === STATUS_PENDING) {
throw this.loadAsync()
}
}
根据上文分析的,React 恰好可以通过 renderRootSync 的 handleError 接住并继续 Suspense 的 second pass 逻辑。
动态加载的 ref 属性转发
我们利用一个小例子来讲清动态加载的组件与 library 的 ref 属性都分别指向了哪里:
// Hello.js
class Hello extends React.Component {
sayHello() {
alert('hello, it"s me');
}
render() {
return <div>Hello</div>;
}
}
// index.js
const Hello = loadable(() => () => import('./Hello'))
const Moment = loadable.lib(() => import('moment'))
function App() {
const helloRef = useRef();
const momentRef = useRef();
return (
<div>
<button
type='button'
onClick={() => {
console.log('helloRef is', helloRef, momentRef);
// helloRef 是否可以获取到 Hello 组件实例方法呢?
helloRef.current.sayHello();
}}
>查看 Hello 组件的 ref 是什么</button>
<Hello name='okami' ref={helloRef} />
<Moment name='okami' ref={momentRef}>{({ default: moment }) => moment().format('HH:mm')}</Moment>
</div>
)
}
在 onClick 中,我们可以看到 helloRef 和 momentRef 分别指向了 Hello 组件和 moment Module:
同样,helloRef 也能调用到 Hello 组件内部的实例方法 sayHello():
这一结论非常符合直觉,但是 @loadable/component 又是如何实现 ref 指向的转发呢?
根据上面的分析,生成 Loadable 组件使用了 React.forwardRef,这意味着将来使用 Loadable 时,设置的 ref 属性作为 props.forwardedRef 传给 EnhancedInnerLoadable 进而传给 InnerLoadable 组件。
而还记得在 InnerLoadable 中 render 方法中的调用吗?
props.forwardedRef 被合并到了 props 里,然后被调用 createLoadable 中配置的 render 方法消费了!
对于 loadable 加载方式来说,上文也提到 createLoadable 方法接收的 render 方法为:
render({ result: Component, props }) {
return <Component {...props} />
},
由于之前的 ref: forwardedRef 作为了 props 的一部分,所以 Component 组件也会被添加 ref 属性:
其实修改为如下方式,会更好理解一点
render({ result: Component, props: propsAndRef }) {
const { ref, ...props } = propsAndRef;
return <Component {...props} ref={ref} />
}
转了一圈,例子中的 helloRef 最终指向了 './hello.js' 模块导出的组件。也就是说,Loadable 组件的 ref 实际指向的是加载成功后的动态加载组件,未加载完成或者加载失败时都指向空。
对于 library 加载方式而言,上文也提到 createLoadable 接收了 onLoad 方法:
onLoad(result, props) {
if (result && props.forwardedRef) {
if (typeof props.forwardedRef === 'function') {
props.forwardedRef(result)
} else {
props.forwardedRef.current = result
}
}
}
我们知道,动态 library 需要依托 Loadable 组件,作为其 children 执行并渲染。同时模块加载的 loadAsync 完成后,会调用 onLoad 钩子,那么 ref 也就指向了 library 模块本身。也就是说,Loadable 组件的 ref 在 library import 场景下指向的是加载成功后的 library 模块本身。
服务端渲染中的 @loadable/component
在系列的第一篇文章中我们介绍到,react-loadable 的动态加载的思路就是将异步动态加载组件转化为同步。相似的,在客户端可以使用 loadableReady 方法的回调函数保证同步加载,类似 react-loadable 中的 preloadReady:
loadableReady(() => {
ReactDOM.hydrate(<App />, root);
});
而在服务端,保证同步的方式是通过在 constructor 中同步加载动态组件/library。createLoadable 生成的 InnerLoadable 组件中提供了一个 this.loadSync 的实例方法,与前面介绍过的 this.loadAsync 十分相似,都起到了加载模块的作用:
而此处的 requireSync 可以通过 babel-plugin 注入,函数核心是使用 require 的同步加载模块:
此外,在服务端使用 loadable 同构时,我们需要通过 @loadable/server 提供的 ChunkExtractorManager 与 ChunkExtractor,配合 @loadable/component 中的 loadable 一起使用:
- ChunkExtractor:提供资源地址管理器的封装。
- ChunkExtractorManager:提供 React Context.Provider,将 ChunkExtractor 注入并通过 props.__chunkExtractor 给 Loadable 组件使用
const ChunkExtractorManager = ({ extractor, children }) => (
<Context.Provider value={extractor}>{children}</Context.Provider>
)
回到上面对 createLoadable 的分析,我们省略的 SSR 的部分如下:
总体来看,SSR 场景下,loadable 生成的组件在浏览器端与服务端均需要完成对动态模块的同步加载,通过将 import 转化为 require 的方式。同时在服务端加载组件时,为 chunkExtractor 记录模块地址信息,以获取 script 与 link 资源的地址。而这些操作,都是在组件构造时在 constructor 中执行的,进而能够保证渲染时可以完整地获取到加载完毕后的资源。
总结与对比
本系列我们介绍了三种 React 常用的动态加载方案,这里我总结了一个对比表格:
React.lazy | react-loadable | @loadable/component | |
---|---|---|---|
通用 library 加载 | ❌ | ❌ | ✅ |
支持 SSR | ✅(v18) | ✅ | ✅ |
支持 Suspense | ✅ | ❌ | ✅ |
支持 import(./${value} ) | ❌ | ❌ | ✅ |
是否支持 timeout、delay | ❌ | ✅ | ❌ |
是否对包体积友好 | ✅ | ❌ | ❌ |
React.lazy + Suspense 的方案,我们将加载中的 promise 通过 throw Error 的方式让 Suspense 接住,再通过 React reconciler 进行整体调度。
react-loadable 与 @loadable/component 都是高阶组件,首次渲染 fallback 组件,挂载后再加载并渲染 primary 组件。同时,在 SSR 场景下,对于动态组件的渲染思路都是在服务器与浏览器双端都保证将异步加载转化为同步进行,服务端同步加载后生成 DOM string 并收集模块地址信息,将 HTML 提供给客户端后,客户端先动态加载需要的资源,完毕后再进行同构。
方案的最终选择需要根据项目具体情况,不过当前 React.lazy + Suspense 可以覆盖绝大多数的动态加载的场景。而对于同构项目或对加载过程的超时、delay 控制要求较高时,考虑 react-loadable 或 @loadable/component 也是不错的选择。
转载自:https://juejin.cn/post/7208188347864547365