这是我参与8月更文挑战的第4天,活动详情查看:8月更文挑战
A higher order component for loading components with dynamic imports.
上篇中我们已经介绍了 React Loadable 在 「CSR」客户端渲染场景下的源码部分,这一篇我们继续来讲在 SSR 场景下的源码部分,同时待着上篇中提出的问题来阅读 SSR 的部分:
- 每个动态加载组件对应的 init 函数都会 push 到 ALL_INITIALIZERS 队列中,那么 ALL_INITIALIZERS 什么时候被调用的呢?
- 我们只插入到 ALL_INITIALIZERS 队列,但是似乎动态加载的工作一直也没有 ALL_INITIALIZERS 的参与。它都做了些什么呢?
SSR场景
SSR ,即服务端渲染。可以理解为:是在 CSR 的基础上加了一层服务端。
❓❓❓在 SSR 场景下,React Loadable 为什么就需要用到 ALL_INITIALIZERS 队列了呢?
讲 ALL_INITIALIZERS 的用处之前,我们先想想:SSR 的场景下直接使用前文中的示例代码会有什么问题吗?
const Footer = Loadable({
loader: () => import('@service/Footer')
loading: Loading,
})
export const App = () => {
return (
<div>
<Footer />
</div>
)
}
-
首先,我们需要将这个 App 渲染成 string。在渲染的过程中是同步的,所以 Footer 组件注定是还没有真实 DOM 的。
为什么同步状态时 Footer 没有真实 DOM 呢?
因为在同步状态时,Loadable 的返回值只是一个 LoadableComponent 组件,真正需要动态加载的组件 @service/Footer 是动态引入的,而动态引入的组件本身就需要在 promise 回调里才能获取到,所以 React 在 renderToString 的时候,Footer 没有真实的 DOM,只有 loading 组件的 DOM。
-
另外,在这种情况下如果什么都不处理的话,返回到前端的首屏 HTML 中 Footer 的部分当然就只有一个 loading 的效果了,这显然不是我们想要的。
SSR 中 直接使用 CSR 中一样的动态加载的方式出现问题了,所以就需要解决这些问题。
我们试想:在 SSR 场景下,提前把组件渲染完毕,或者等待组件渲染完毕后再返回给前端是不是就可以了?
其实这句话里包含了两个方案:
- 当请求对应的懒加载组件时等待真实 DOM 渲染完毕再返回这个组件
- 在服务启动时把组件渲染完毕
第一种方案其实存在两个问题:
- 如果请求对应的组件时再去等待加载,就必定有一个等待的时间。
- 其次如果有 n 个用户访问的页面都需要这个组件,那么每个用户都需要等待。
所以,我们排除了第一种方案。
React Loadable 也是采用了第二种方案的 😬
那它是怎么做的呢?
这里也会揭晓 ALL_INITIALIZERS 队列和 READY_INITIALIZERS 的作用。
我们先改造一下 客户端的代码:
window.main = () => {
Loadable.preloadReady().then(() => {
ReactDOM.hydrate(<App/>, document.getElementById('app'));
});
};
原本我们直接 ReactDOM.hydrate 渲染 App 就可以了,但现在我们做了两件事:
-
将其封装在 main 函数中
因为通过 React Loadable 把很多组件都做了动态加载,所以肯定需要等所有动态加载的组件都加载完毕后才能开始渲染,不然肯定会报错:'找不到 xx 组件'。
-
需要等待 Loadable.preloadReady() 执行完成后才能渲染
我们参照上面两种 SSR 渲染方案,选择的第二种
服务启动时就需要把组件加载完毕,所以 preloadReady 就是这个作用。我们看一下代码:
Loadable.preloadReady = () => {
return new Promise((resolve, reject) => {
flushInitializers(READY_INITIALIZERS).then(resolve, resolve);
});
};
- flushInitializers 方法的作用:遍历传入的任务队列并通过 Promise.all 批量执行,具体的下文会继续分析。
- 可以看到 preloadReady 返回了一个 Promise,并且将 READY_INITIALIZERS队列传入。
- 最后无论成功或失败,都会 resolve。
这里面我们从三个点去分析:
-
flushInitializers 做了什么?
我们看看 flushInitializers 的源码👇
function flushInitializers(initializers) { let promises = []; while (initializers.length) { let init = initializers.pop(); promises.push(init()); } return Promise.all(promises).then(() => { if (initializers.length) { return flushInitializers(initializers); } }); }-
入参 initializers 是一个队列
-
遍历整个队列,并依次添加到 promises 数组中
-
通过 Promise.all 执行该数组,最终 resolve
-
但这里有一个问题,在执行 Promise.all() 前,看起来 initializers 一定为空。那为什么 Promise.all() 的回调里仍要判断 initializers.length 呢?
我们通过一个🌰 来解释一下吧:
function test(array) {
console.log(array.length)
setTimeout(() => {
console.log(array.length)
})
}
const arr = []
test(arr)
arr.push(1) // 0 1
-
为什么打印结果是0 、1呢?
因为这个例子中传递的数组只是一个引用,当 arr.push(1) 的时候,test 函数内的 array 只是 arr 的一个引用,执行到 setTimeout 内部的时候,取 array 的值就等价于取 arr 的值,所以第二个打印的就是 1 了。
-
回到问题本身,可以发现虽然 flushInitializers 在 while 循环时已经清空了 initializers ,但是 Promise.all() 执行过程中,可能又往 READY_INITIALIZERS 里 push 了内容。所以在回调之后仍然需要再次判断。
-
和刚才的例子对比:initializers 就相当于 array,而 READY_INITIALIZERS 就相当于 arr。
形参 initializers 其实指向的是 READY_INITIALIZERS,所以 Promise.all() 执行完任务时仍需要判断 initializers 是否为空。
-
READY_INITIALIZERS 是什么?
讲 CSR 的场景时,我们提到了 READY_INITIALIZERS:
if (typeof opts.webpack === "function") { READY_INITIALIZERS.push(() => { if (isWebpackReady(opts.webpack)) { return init(); } }); }如果配置参数传入了 webpack,就会向队列中添加一个任务,当 WebpackReady 的时候就执行 init 函数进行加载组件。
那这个 WebpackReady 判断了什么呢?
function isWebpackReady(getModuleIds) {
if (typeof __webpack_modules__ !== "object") {
return false;
}
return getModuleIds().every(moduleId => {
return (
typeof moduleId !== "undefined" &&
typeof __webpack_modules__[moduleId] !== "undefined"
);
});
}
- webpack_modules 是 webpack 打包后产生的一个变量,上面绑定了很多方法。所以
isWebpackReady先判断是否处于 webpack 环境。 getModuleIds的返回值就是传入 React Loadable 时的 webpack 参数值,它需要是一个包含 moduleId 的数组,这些 moduleId 最终会被定义在 **webpack_modules ** 对象中。- 整体来看,
isWebpackReady方法就是判断 webpack 是否加载完毕、指定的懒加载模块是否已被正确的定义了。
总结来说:READY_INITIALIZERS 就是存储在 ReactDOM render 前就必须加载好的任务。
为什么 preloadReady 无论成功还是失败都会 resolve(视为成功)呢?
- 首先,大家要知道 preloadReady 的本质就是懒加载模块的动态引入是否成功:
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;
}
- 另外,我们在使用 rl 时候传入了一个 Loading 组件,关于动态引入懒加载模块报错的逻辑,在 LoadableComponent 中也提到过了,再回顾一下:
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;
}
}
从 render 函数的第一个判断条件,可以看到如果引入懒加载模块报错时,会进入渲染 Loading 组件的逻辑。
所以关于为什么 preloadReady 里成功或者失败都是 resolve 的原因就可以揭晓了:
因为加载失败会进入 Loading 组件的渲染,所以可以理解为这也是一种正常情况。
总结
React Loadable 这个库的设计还是非常巧妙的。
- 利用了 webpack 支持动态 import 的特性
- 把异步任务对应的Promise、执行结果、加载状态等封装在一个对象中,通过这个对象获取到异步任务的所有信息
- 既支持 CSR ,也支持 SSR
欢迎大家交流分享!