这是我参与8月更文挑战的第3天,活动详情查看:8月更文挑战
A higher order component for loading components with dynamic imports.
用于加载具有动态导入的组件的高阶组件。
React Loadable 使「以组件为中心的代码分割」变得非常容易,通常用来做动态加载。
「动态加载」这个话题其实诞生已久,现在的日常开发中,也是必不可少的一环,它对线上应用的性能有着至关重要的作用!
通常 React 应用会使用 Webpack 进行构建,但随着项目内容的丰富我们会发现项目包越来越大,项目运行越来越慢,这个时候就需要对代码进行拆分了。
为了更好地了解 React Loadable,我们会先从Webpack聊起。
从Webpack说起
我们知道,webpack 内置了对动态 import 的支持,下面是一个最基础的 demo:
// a.js
import('./b')
// b.js
console.log('i am b')
// webpack.config.js
const path = require('path')
const resolve = p => path.resolve(__dirname, p)
module.exports = {
mode: 'development',
entry: resolve('a.js'),
output: {
filename: 'bundle.js',
path: resolve('dist')
}
}
上述代码在执行 webpack 打包后,在 dist 文件夹中生成的目录树如下:
从打包结果可以看到:webpack 会对动态引入的文件单独打成一个新的子包。
这样就实现了Webpack中最基本的「组件动态加载」。
接下来进入今天的主题 ---- React Loadable是如何实现组件动态加载的?
React Loadable的使用
深入学习这个库之前,我们需要先了解它的用法,因为你可能还没有在项目中用 React Loadable 做过动态加载。
官方文档中给出了一个比较简单易懂的 DEMO :
import React from 'react'
import Lodable from 'react-lodable'
import Loading from '@common/loading' // Loading 组件
const Footer = Loadable({
// 当前业务中期望被懒加载的组件
loader: () => import('@service/Footer'),
loading: Loading,
})
export const App = () => {
return (
<div>
<Footer />
<div>
)
}
上面短短几行代码就完成了一个最简单的 React Loadable 的应用。
大家有没有发现原本我们是可以用webpack的动态 import 特性import('@service/Footer') 就可以实现动态加载,而现在使用 React Loadable 实现的动态加载还需要把 import('@service/Footer') 嵌套在一个对象中。
看到这里,大家可能会猜 React Loadable 应该是做了什么”手脚“,封装了一些 Loading 之类的功能。
想要知道它到底做了哪些”手脚“,带着我们的推测往下看看它的源码中都做了什么?
先提问题
解析 React Loadable 源码之前我们提两个问题,带着问题去理解代码。
-
前面我们了解到 webpack 已经提供了成熟的动态加载方案,那 React Loadable 存在的意义又是什么呢?
-
在服务端渲染(SSR)的场景下做动态加载有什么需要注意的吗?React Loadable 在这其中会做些什么呢?
后文中我们会按照使用场景「CSR & SSR」逐个分析。
前言
写在前面:React Loadable 内部使用了一个封装的小套路。
为了便于大家更好地理解这个所谓的「小套路」是什么,我们先来举个栗子🌰:
function load(delayTask) {
const state = {
loaded: false,
result: null,
promise: null,
}
const Defer = delayTask()
.then(value => {
state.loaded = true
state.result = value
}).catch(err => {
state.loaded = true
state.result = value
})
state.promise = Defer
return state
}
你看出这段代码中有什么「小套路」了吗?
load 函数接收了一个异步任务「delayTask」参数,在函数内部定义了一个 对象 state,最终返回了这个 state 对象。
- 在 state 上,绑定了 delayTask 任务的一些相关信息,包括加载状态、执行结果、异步执行的 promise。
- 因为 load 函数最终会返回 state 对象,所以我们只需要在某个时刻去看返回的 promise 状态就可以判断任务是否完成。
- 另外,我们可以直接获取到任务的执行结果及其他信息。
const state = load(() => {/* do something */})
function check() {
const { loaded, result } = state
if (loaded) {
console.log(result)
}
}
// 时刻 A
check()
// 时刻 B
check()
在 state.promise「即Defer」 中会在某个时刻修改 state.loaded,因此我们每次通过 state.loaded 获取的都是最新的状态值。
这其中利用了 JS 中传递的对象是引用类型的值,始终可以通过这个引用获取到最新的值的原理。
React Loadable中的「小套路」也是同样的:把异步任务对应的Promise、执行结果、加载状态等封装在一个对象中,通过这个对象就可以获取到异步任务的所有信息。
回归主题,下面我们就接着看看 React Loadable 在两种不同的场景下分别是如何实现动态加载的?
CSR场景
CSR,即客户端渲染。
还记得文章开头提供的官方demo吗?那其实就是一个典型的 CSR 使用场景下的例子。
下面我们一起看看在 CSR 场景下 React Loadable 的一些核心代码吧⤵️
❓❓❓React Loadable 是怎么应用这个套路的呢?
先看一段源码👇
function Loadable(options) {
let res = null
function init() {
if (!res) {
// loadFn() 将加载「动态引入模块」的结果 赋给 res
res = loadFn(options.loader)
}
return res.promise
}
ALL_INITIALIZERS.push(init)
if (typeof options.webpack === 'function') {
READY_INITIALIZERS.push(() => {
if (isWebpackReady(options.webpack)) {
return init()
}
})
}
return class LoadableComponent extends React.Component {
// ...
}
}
TIPS:代码中 ALL_INITIALIZERS 和 READY_INITIALIZERS 的部分我们会在后面讲解😏
-
这段代码正是套用了前面说的「小套路」:先定义一个 res 变量,在 init 函数的内部把 loadFn 的结果赋值给 res ,然后返回 res 上的 promise 。
因此我们在后文就可以这么去做:
const promise = init()
promise.then(() => {
// ...
})
- Loadable 函数最终返回了一个 React 组件「LoadableComponent」,因此在第一个 React Loadable 应用的 DEMO 中我们可以直接将传入 react-loadable 函数的结果作为一个组件去使用。
const Footer = Loadable({
loader: () => import('@service/Footer'),
loading: Loading,
})
export const App = () => {
return (
<div>
<Footer />
</div>
)
}
❓❓❓那返回的这个 LoadableComponent 组件内部都做了什么呢?
我们把这个组件一一展开:
- 首先,constructor 部分👇
constructor(props) {
super(props);
init(); // 还记得前面我们定义过的 init 函数吗?是的,就是它!
this.state = {
error: res.error,
pastDelay: false,
timedOut: false,
loading: res.loading,
loaded: res.loaded
};
}
-
在 constructor 中会执行前面提到过的 init 函数,然后会在 state 上绑定一些参数,包括 error、timedOut、loading 等。
-
接下来,看看 init 函数里都干了些什么?(O_O)
init 函数的本质是一个 load 函数,接收的参数是传入 Loadable 中的 loader(也是一个函数),并且这个函数可以返回动态引入的 Footer 组件(即需要被动态加载的组件)。
function load(loader) {
let promise = loader();
let state = {
loading: true,
loaded: null,
error: null
};
state.promise = promise
.then(loaded => {
state.loading = false;
state.loaded = loaded;
return loaded;
})
.catch(err => {
state.loading = false;
state.error = err;
throw err;
});
return state;
}
上面的 load 函数是不是看起来非常熟悉?
没错!这里也用到了「小套路」:先创建一个对象,然后把加载的状态、promise 都添加到该对象上。
- 代码中首先执行了 loader 函数,并把执行结果赋值给 promise 变量,也就是说 这里的 promise 其实就是动态引入的 Footer 组件 。
- 然后创建一个 state 对象,并给 state 对象绑定了一个 promise 参数,后面会在 promise 回调中修改 state 中 loading、loaded、error 等相关参数的值。
- 最后,返回这个 state 对象。
由此得出结论:在 LoadableComponent 组件初始化(即constructor)时就会去加载 Footer 组件。
❓❓❓ LoadableComponent 除了上面这些,还做了什么呢?
继续看源码👇
componentWillMount() {
this._loadModule()
}
render() {
if (this.state.loading || this.state.error) {
return React.createElement(opts.loading, {
isLoading: this.state.loading,
pastDelay: this.state.pastDelay,
timedOut: this.state.timedOut,
error: this.state.error,
retry: this.retry
});
} else if (this.state.loaded) {
return opts.render(this.state.loaded, this.props);
} else {
return null;
}
}
-
首先,组件渲染前,先执行了
this._loadModule这里从函数名可以猜到:这应该是用来加载传入的动态加载组件的。详情我们后文也会讲到😛
-
然后,看render 部分,代码分成了三个岔路:
- 加载中或加载失败时,渲染传入到 React Loadable 的 Loading 组件,并将 isLoading、error、retry 等作为参数传入
- 加载完毕,渲染真正需要动态加载的组件
- 其他情况,返回 null
这里返回 null 的目的是,我们可能在某些场景下不需要显示 Loading,也不需要显示真正的组件。
⚠️ 敲黑板啦 ⚠️
思考🤔一下:加载中或加载失败的状态时,传入了一个 retry 参数 ,那这个 retry 是做什么用的呢?
retry = () => {
this.setState({ error: null, loading: true, timedOut: false });
res = loadFn(opts.loader);
this._loadModule();
};
- retry 是一个方法,用来做重新加载的。
- 执行这个方法:
-
首先会将 error、loading、timedOut 的值重置
-
然后会重新加载真正的组件
-
最后执行 this._loadModule 函数。
-
到这里,我们已经知道了 在 componentWillMount 和 retry 中都会执行this._loadModule()。
❓❓❓那 this._loadModule() 这个方法中都做了什么?它和 loadFn(opts.loader) 做的事情有什么关系呢?
这个部分,我们来看看 this._loadModule() 的源码👇
- 首先对 res.loading 做判断:如果已经加载完毕,直接退出。
_loadModule() {
if (!res.loading) { // 如果已经加载完毕,直接退出。
return;
}
}
- 接着定义一个 setStateWithMountCheck 方法,如果已经渲染完毕,就更新传入的值。
_loadModule() {
// 检查 loading 状态
let setStateWithMountCheck = (newState) => {
if (!this._mounted) { // 如果已经渲染完毕,就更新传入的值。
return;
}
this.setState(newState);
}
}
- 然后在哪会调用这个 setStateWithMountCheck 方法呢?
_loadModule() {
// 检查 loading 状态
// 定义 setStateWithCheck 方法
if (typeof opts.delay === 'number') {
if (opts.delay === 0) {
this.setState({ pastDelay: true });
} else {
this._delay = setTimeout(() => {
setStateWithMountCheck({ pastDelay: true });
}, opts.delay);
}
}
if (typeof opts.timeout === "number") {
this._timeout = setTimeout(() => {
setStateWithMountCheck({ timedOut: true });
}, opts.timeout);
}
}
这里主要判断是否传入了 delay 、timeout 参数
这两个参数最终都会被传入到 Loading 组件,如果我们传入的 Loading 组件只是单纯的菊花图,那么这些参数可能都用不到。
所以,它们的作用就是对 Loading 组件作更细分的判断场景的渲染。
- 最后更新状态
_loadModule() {
// 检查 loading 状态
// 定义 setStateWithCheck 方法
// 判断 delay、timeout 参数
let update = () => {
setStateWithMountCheck({
error: res.error,
loaded: res.loaded,
loading: res.loading
});
this._clearTimeouts();
};
res.promise
.then(() => {
update();
return null;
})
.catch(err => {
update();
return null;
});
}
-
这一段代码首先定义了 update 方法,目的是将 res 对象的各种状态更新到 state 中,最终触发 React 渲染。
因为 res 只是普通的 JS 对象,更改 res 的属性不会触发页面更新。
我们已经看完了_loadModule() 的全部源码,那我们一起回顾下前面提的两个问题:
- _loadModule 这个方法里做了什么?
- _loadModule 和 loadFn(opts.loader)有什么关系?
-
首先,loadFn(opts.loader) 会先检查是否已经存在 res,不存在则去加载真正的组件,并返回一个包含加载状态及结果的 res 对象。
-
然后,_loadModule 会去判断 res 是否存在 delay、timeout 等参数,并且会在组件加载完毕(res.promise.then)时去将 res 的结果赋值给 state ,同时触发渲染。
-
所以二者的关系是:先通过 loadFn 去创建加载组件的对象,然后 _loadModule 根据 loadFn 返回的对象判断加载结果,最终触发渲染。
CSR场景下的动态加载部分至此就全部讲完了,我们再回过头看一下前面开始那一段代码:
function Loadable(options) {
let res = null
function init() {
if (!res) {
res = loadFn(opts.loader);
}
return res.promise;
}
ALL_INITIALIZERS.push(init);
// ...
return class LoadableComponent exends React.Component {
// ...
}
}
前面我们说 ALL_INITIALIZERS 和 READY_INITIALIZERS 会在后面讲到,但直到 CSR 场景全部讲完,也没有解析到这一部分。
因为我们的示例代码是 CSR (客户端渲染)的场景,所以有些代码没有用到也实属正常。
所以,我们自然也能想到一定在另一种场景 SSR 中起到了一定的作用。
老规矩,我们还是先思考两个问题,带着问题看源码:
- 每个动态加载组件对应的 init 函数都会 push 到 ALL_INITIALIZERS 队列中,那么 ALL_INITIALIZERS 什么时候被调用的呢?
- 我们只插入到 ALL_INITIALIZERS 队列,但是似乎动态加载的工作一直也没有 ALL_INITIALIZERS 的参与。它都做了些什么呢?
我们在下篇中继续回答这个问题。