本系列作为 SPA 单页应用相关技术栈的探索与解析,先从 React 动态加载角度入手,探索市面当前流行的方案的实现原理。
笔者毕业后接触的第一个项目是一个重交互且复杂的单页应用(SPA),其特点是仅有一份 HTML ,多个页面承载多种业务逻辑,通过路由跳转的方式来协调。随着产品需求的不断迭代,项目复杂度越来越高,代码体积也迅速膨胀。特别是笔者负责的企业微信文档融合项目,更是直接将代码量翻了个倍。
作为千万pv的用户产品,数十万行的代码量的前端应用,如果没有一个良好的体积控制与模块加载方案,性能与用户体验将是灾难性的。基于 SPA 的特性,自然可以想到通过按需加载的方式,等到用户需要进入某些页面/组件时再加载对应的模块代码。
说说代码分割(code-splitting)
幸运的是,webpack等打包工具已经支持各种 code-splitting 策略进行优化代码体积。code-splitting,顾名思义,就是将一个完整的 bundle 拆分成多个,实现按需加载或被浏览器缓存,进而实现前端应用代码量加载体积减少。
import()
ES 标准中的 import() 函数提供了原生动态加载支持,例如:
import Module from './bar.js';
console.log(Module);
可以改写成:
import('./bar.js').then(Module => {
console.log(Module);
});
我们使用 babel 对其编译结果如下:
理解起来并不复杂:import 将模块内容转换为 ESM 标准的数据结构后,通过 promise 形式返回,加载完成后获取 Module 并在 then 中注册回调函数。
此外,当 webpack 检测到 import() 存在时,将会自动进行 code-splitting,将动态 import 的模块打到一个新 bundle 中:
如果我们希望给动态加载的 bundle 指定命名,也可以在 import 中插入注释:
import((/* webpackChunkName: "bar" */ './bar.js').then(Module => {
console.log(Module);
});
可以看到,这里动态加载的 bundle 就变成了我们指定的 bar ,配上后缀的 .chunk.js。
一个简易版的动态加载方案
有了 code-splitting 和 import 的理论基础,我们可以实现一个简易版的组件动态加载方案:
import Loading from './components/loading';
const MyComponent: React.FC<{}> = () => {
const [Bar, setBar] = useState(null);
// 首次 render 前加载 Bar 组件,加载完成后设置 this.state.Bar
// 起到 componentWillMount 的效果
const firstStateRef = useRef({});
if (firstStateRef.current) {
firstStateRef.current = undefined;
import(/* webpackChunkName: "bar" */ './components/Bar').then(Module => {
setBar(Module.default);
});
}
if (!Bar) return <Loading />;
return <Bar />;
}
上述代码在组件渲染之前,先加载 Bar 组件,加载完成前先渲染 Loading,完成后再重新渲染 Bar 组件。然而,对于每个动态加载组件都需要补充生命周期,兼容加载失败与未完成等场景,是十分复杂且不优雅的。为了更好地封装与复用,同时处理各种附加场景,本系列我们研究几种 React 相关的动态加载方案,结合源码来给大家讲解其实现原理。
React-loadable 是什么
引用官方文档的描述: Loadable is a higher-order component (a function that creates a component) which lets you dynamically load any module before rendering it into your app.
简单来说,react-loadable 提供了一个动态加载任意模块(主要是UI组件)的函数,返回一个封装了动态加载模块(组件)的高阶组件。通过传入诸如模块加载函数、Loading 状态组件、超时等配置参数,统一封装并处理动态加载成功与异常等场景。
React-loadable 改造开头的例子
回到文章开头的例子中,我们可以利用 react-loadable 将其改造成 Loadable Component:
import Loadable from 'react-loadable';
import Loading from './components/loading';
const MyComponent = Loadable({
loader: () => import('./components/Bar'),
loading: () => <Loading />,
});
此外,react-loadable 还支持多资源动态加载,借用官方文档的例子:
Loadable.Map({
loader: {
Bar: () => import('./Bar'),
i18n: () => fetch('./i18n/bar.json').then(res => res.json()),
},
render(loaded, props) {
// loaded 此时是一个对象,key 对应 loader 传入的 key
// 访问 loaded[key] 即可获取加载完成后的对应模块
let Bar = loaded.Bar.default;
let i18n = loaded.i18n;
return <Bar {...props} i18n={i18n}/>;
},
});
下面结合源码来分析下 react-loadable 动态加载的原理。
Loadable Component 是如何支持动态加载的?
Loadable 的核心是 createLoadableComponent 函数,采用策略模式,根据不同他场景(单资源 or 多资源 Map)传入对应的 load/loadMap 方法:
Loadable.Map 必须传入 render 方法,而 Loadable 则不需要,原因再分析到 createLoadableComponent 时自然就有答案了,这里我们先跳过,来看看上面的 load 和 loadMap 参数分别是什么:
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;
}
function loadMap(obj) {
let state = {
loading: false,
loaded: {},
error: null
};
let promises = [];
try {
Object.keys(obj).forEach(key => {
let result = load(obj[key]);
if (!result.loading) {
state.loaded[key] = result.loaded;
state.error = result.error;
} else {
state.loading = true;
}
promises.push(result.promise);
result.promise
.then(res => {
state.loaded[key] = res;
})
.catch(err => {
state.error = err;
});
});
} catch (err) {
state.error = err;
}
state.promise = Promise.all(promises)
.then(res => {
state.loading = false;
return res;
})
.catch(err => {
state.loading = false;
throw err;
});
return state;
}
load 方法其实就是对传入的 loader 加载器进行调用并封装其加载状态与结果。loadMap 接收一个 Object ,key-value 分别为 key 以及对应的 loader,分别调用 load 方法,加载传入 Object 中所有 loader。
策略模式种的两种加载器已经分析完了,接下来就让我们看看核心的工厂方法 createLoadableComponent:
function createLoadableComponent(loadFn, options) {
// 必须传入 loading 组件
if (!options.loading) {
throw new Error("react-loadable requires a `loading` component");
}
// 初始化 options 参数
let opts = Object.assign({
loader: null, // loader 加载器函数
loading: null, // 是否处于 loading 状态
delay: 200, // loading 过程中,且超过了这个时间,会展示 loading 中的 pastDelay 组件
timeout: null, // 超时时间,超时后展示 Loading 中的 timedOut 组件
render: render, // render 方法,默认 React.createElement 渲染
webpack: null, // webpack加载模块函数(loader自动添加)
modules: null // 动态加载本地资源的模块地址(loader自动添加)
}, options);
let res = null;
// 加载函数:调用 loadFn (上面分析的load或者loadMap)并返回模块的 promise
function init() {
if (!res) {
res = loadFn(opts.loader);
}
return res.promise;
}
// 将所有加载函数注册进 ALL_INITIALIZERS 数组里(SSR 用)
ALL_INITIALIZERS.push(init);
// 对于动态加载的本地资源模块注册进 READY_INITIALIZERS 数组里(SSR 用)
if (typeof opts.webpack === "function") {
READY_INITIALIZERS.push(() => {
if (isWebpackReady(opts.webpack)) {
return init();
}
});
}
// 返回高阶组件,下面继续分析
return LoadableComponent;
}
在调用 Loadable({ xxx }) 后,会经历1)初始化参数,2)加载函数存入ALL_INITIALIZERS、READY_INITIALIZERS 以便 SSR 场景下实现同步渲染,3)返回 LoadableComponent 高阶组件。其实 LoadableComponent 高阶组件的实现逻辑很简单,和文章开头 简易版的动态加载方案 中的实现很相似,不过增加了一些场景的封装,总结核心逻辑如下:
上图中省略了部分逻辑,而折叠的 _loadModule 方法主要负责给动态加载的组件 promise 注册完成与失败时更新组件 state 的逻辑,大致如下:
此外,我们注意到 LoadableComponent 高阶组件在动态加载完成时,会调用 opts.render 方法,默认使用 React.createElement() :
function resolve(obj) {
return obj && obj.__esModule ? obj.default : obj;
}
function render(loaded, props) {
return React.createElement(resolve(loaded), props);
}
当我们使用 Loadable.Map 时,由于传入的 loader 是一个对象,调用上面的默认 render 自然会失败,这也是为什么使用 Loadable.Map 时需要显式传入 render 函数的原因。loaded 对象上附带了 loader 加载后生成的 Object,直接通过 loaded[key] 获取对应的动态组件即可。
服务端渲染中的 Loadable Component
对于 SPA 来说,服务端渲染(SSR)+ 同构的场景是十分常见的,需要服务端生成对应的 DOM string 与 HTML 并返回给客户端,客户端渲染 DOM,加载 JS 后再复用同一份 javascript 代码注水。
而动态加载的组件,在渲染前是无法得知自身的真实 DOM 结构的,也就意味着 SSR 场景下的 HTML 将无法获得准确的组件 DOM string。(总不能直接挂个 XXXLazy 在那吧)所以我们很自然能想到一种简单的解决方案:把异步的动态加载,变成同步的就好了!
在 React 18版本之前,React.lazy + Suspense 的原生方案不支持 SSR 。而 react-loadable 基于将异步转化为同步的方案,提供一套简单直观的 API 支持 SSR:
-
Loadable.Capture 高阶组件:用于服务端,获取子组件中的 Loadable Component 并暴露函数上报需要动态加载的本地模块。(用于拼接 HTML script 中的 js 地址,例如 src='dist/a.js')
-
Loadable.preloadAll:用于服务端,等待所有模块都动态加载完毕后,生成确定的 DOM 结构。
-
Loadable.preloadReady:用于客户端,等待所有指定的动态加载的本地模块加载完毕后,调用 hydrate 复用 DOM 结构。
用一个源码中简单的 demo 来表示 SSR 场景下的使用:
客户端渲染代码
// client.js
window.main = () => {
// 等待所有本地 module 加载完后进行 hydrate,完成前仍展示 SSR 的 DOM
Loadable.preloadReady().then(() => {
ReactDOM.hydrate(<App/>, document.getElementById('app'));
});
};
服务端渲染代码
// server.js
app.get('/', (req, res) => {
let modules = [];
// Capture: 用于获取本地 module ,生成 bundles 进而拼接 HTML
let html = ReactDOMServer.renderToString(
<Loadable.Capture report={moduleName => modules.push(moduleName)}>
<App/>
</Loadable.Capture>
);
let bundles = getBundles(stats, modules);
let scripts = bundles.filter(bundle => bundle.file.endsWith('.js'));
res.send(`
<!doctype html>
<html lang="en">
<body>
<div id="app">${html}</div>
<script src="/dist/main.js"></script>
${scripts.map(script => {
return `<script src="/dist/${script.file}"></script>`
}).join('\n')}
<script>window.main();</script>
</body>
</html>
`);
});
app.use('/dist', express.static(path.join(__dirname, 'dist')));
// 等待所有 Loadable Component 加载完毕后,开启 server
Loadable.preloadAll().then(() => {
app.listen(3000, () => {
console.log('Running on http://localhost:3000/');
});
}).catch(err => {
console.log(err);
});
大致的流程可以用下面的流程图来表示:
理解了上述流程,对应 API 的是实现原理相比也不难猜出:
- Capture 仅需要在上层暴露一个 report 方法的 context 属性,Loadable Component 子组件在 _loadModule 中调用:
const CaptureContext = createContext(undefined)
CaptureContext.displayName = 'Capture'
function Capture({report, children}) {
return <CaptureContext.Provider value={report}>
{children}
</CaptureContext.Provider>
}
- preloadAll 和 preloadReady 通过 promise.all 处理所有 loader,同时在 then 方法中继续递归,以防止第一层的 Loadable Component 加载完成后,其 loader 加载模块内部仍然有 Loadable Component 的场景:
function flushInitializers(initializers) {
let promises = [];
while (initializers.length) {
let init = initializers.pop();
promises.push(init());
}
// 处理循环 Loadable Component 场景
// 例如 Loadable 的 loader 中还有 Loadable Component
return Promise.all(promises).then(() => {
if (initializers.length) {
return flushInitializers(initializers);
}
});
}
Loadable.preloadAll = () => {
return new Promise((resolve, reject) => {
flushInitializers(ALL_INITIALIZERS).then(resolve, reject);
});
};
Loadable.preloadReady = () => {
return new Promise((resolve, reject) => {
flushInitializers(READY_INITIALIZERS).then(resolve, resolve);
});
};
局限性
React loadable 的原始仓库自从 2020 年开始就不再更新维护了,适用的 webpack 与 babel 版本也相对局限,更重要的是,其使用的 React 版本为 16.5.2,对于现代基于 Hooks 等新 React 项目并不兼容,往往需要重复打包 React 才能正常工作。
对此 @react-loadable/revised 包在保留原有功能的基础上进行了 Hooks + ts 重构,弥补了现有的局限性。
React-loadable 的实现原理和思路相对简洁直观,下一篇文章,我们介绍 React.lazy + Suspense 的原生解决方案。作为 React Fiber + reconciler 架构的一环,理解了 ReactLazy 不仅能够对动态加载的实现思路有深度理解,也能帮助理解 Fiber 架构。