React
和Vue
都是可以实现组件的异步加载的,React
最新的版本已经有了React.lazy
和Suspense
来动态引入组件,以前网上都是使用webpack
提供的插件来进行异步加载组件的。
但是该文章分析的是Vue
的异步组件加载。
原因如下:
- 第一,工作上刚好碰到了需要对
Vue
实现的组件库进行模块拆分支持异步加载的模式。 - 第二,目前网上有针对
Vue
源码分析的文章,讲的比较清晰。 - 第三,
React
的源码不是很看得懂。
接下来我通过vue-cli
的build
命令来创建一个库,从这里出发,来看看异步加载是怎么和webpack
结合和支持的。有机会再看看React
是怎么做的。
Vue-cli 创建项目
我们在全局安装@vue/cli
来初始化一个Vue
的基本项目结构,一路默认就行,不需要其他操作,不影响分析。
vue create vue-async-component-webpack
看一下目录结构:
创建库的入口文件
先在src
下面创建一个lib.js
文件,用来输出Vue
的组件,实现install
方法,可以让使用方使用Vue.use(mylib)
这样去调用。
// src/lib.js
const install = function (Vue, _ops) { // 写下划线只是为了让我的eslint不报错而已,强迫症,不用在意。
// ops 可以为Vue.use(lib, {}) 这样调用时候传入的初始化参数。。。
// 我们这里直接把App.vue当做组件输出出去
Vue.component('my-component', () => import('./App.vue'))
// 这里还可以注册其他组件或者做自己的的一些初始化操作等
}
export default {
install, // 这里导出的install会被use的时候调用,这些是Vue的一些原理或者称为原理吧
}
Vue
官网文档上面,介绍Vue
的异步组件注册有三种模式,以上是其中一种,后面会根据一个Vue
源码分析系列博客基本的分析一下异步注册的原理。
创建打包命令
我们修改package.josn
,添加打包组件库的命令,这些命令vue-cli
已经提供了。
// package.json
...
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"build:lib": "vue-cli-service build --target lib --name mylib src/lib.js",
"lint": "vue-cli-service lint"
},
...
打包库
运行这条命令,得到打包后没有压缩的代码进行分析。
npm run build:lib
好了,现在组件库打包完了,现在就开始进行分析吧。
webpack打包后的文件分析
我们打开dist/mylib.umd.js
文件,这里面不具体说webpack
最后打包的代码结构,用图片解释一下吧。
script
标签的加载等。函数执行的时候传入了一个工厂函数,用来获取整个App
。webpack
的结构先说到这里吧,网上有很多文章介绍,这里先不说了。
我们先直接在文件里面搜索install
函数,看看被转换成什么样子了。
如上图,我们主要关注vue.component
这行代码:
return __webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, "3dfd"));
看到,返回了一个__webpack_require__.e
函数的执行结果,看到了then
,那肯定就是一个Promise
的对象了。
找到 __webpack_require__.e
函数,一步一步溯源:
中间的if/else
我先折叠了,可看到注释,就是来判断加载的资源是css
还是js
,那么刚才install
函数那里调用的地方,传进来的值为1
,看看代码:
...
// object to store loaded CSS chunks
var installedCssChunks = {
0: 0
}
...
if(installedCssChunks[chunkId]) promises.push(installedCssChunks[chunkId]);
else if(installedCssChunks[chunkId] !== 0 && cssChunks[chunkId]) {
promises.push(installedCssChunks[chunkId] = new Promise(function(resolve, reject) {
var href = "css/" + ({}[chunkId]||chunkId) + "." + {"1":"e2713bb0"}[chunkId] + ".css";
var fullhref = __webpack_require__.p + href;
var existingLinkTags = document.getElementsByTagName("link");
...
}
根据代码判断,这里去加载需要的css文件去了。因为重点是js
文件,这里的css
就不具体展开说了,其实加载和js
如出一辙,区别不大。
继续往下面看,现在看加载js
的代码:
// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if(installedChunkData !== 0) { // 0 means "already installed".
// a Promise means "currently loading".
if(installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
...
script.src = jsonpScriptSrc(chunkId); // 可看 sonpScriptSrc 函数,里面已经写好了 js 文件的名称
onScriptComplete = function (event) {
// ... 省略,主要是处理加载出错了返回错误信息
};
var timeout = setTimeout(function(){
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
script.onerror = script.onload = onScriptComplete;
document.head.appendChild(script);
}
}
跟css
的加载很像,这里是先判断一下这个文件是否已经被加载过,如果没有则去创建一个Promise
,Promise
里面之后会去创建script
标签,然后设置src
的路径,这里面就会去请求被拆分出去的组件代码了。
到这里就可以明白,它的步骤被分为这样几步:
-
其实异步加载组价也就是去创建了
script
标签,然后把代码下载下来。 -
当代码被浏览器成功加载后,里面的函数会往一个数组里面
push
一个对象,这个数组的push
方法在dist/mylib.umd.js
文件中被重写了,导致push
函数的执行其实是去执行了一个叫webpackJsonpCallback
的函数,从字面意思上可以看出是一个jsonp
的回调函数。 这里可能需要贴几张图
dist/mylib.umd.js
文件里面, 重写某个数组的 push 方法
mylib.common.1.js
或者 mylib.common.[2,3,4....].js
,就看分了多少吧,都有这个函数的。这里我折叠了一下,知道逻辑就行。
- 当 push 方法被执行的时候,我们通过第一步就尅看到,其实是执行的
dist/mylib.umd.js
里面的webpackJsonpCallback
函数:
function webpackJsonpCallback(data) {
...
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId]) {
// 关键是这里,将istalledChunks[chunkId][0]
// 也就是`e`函数里面创建`Promise`时,里面的`resolve`函数。
resolves.push(installedChunks[chunkId][0]);
}
...
while(resolves.length) {
// 出队列去执行 resolve 函数
resolves.shift()();
}
...
}
当执行了 resolve
函数后,我们终于可以回到 install
函数了,我们继续看install
函数的后面一节。
then 的回调函数
return __webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, "3dfd"));
回到install
函数的then
,看到传入了一个函数__webpack_require__
,并且手动绑定了一个参数3dfd
, 其实这个参数就是我们需要异步加载的那个组件对象的映射key
。我们可以先看__webpack_require__
函数。
function __webpack_require__(moduleId) {
// 检查是否已经被加载过,如果是就直接返回对象的 exports ,这种检查还真是多
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 创建一个 ”模板“ 的对象,里面包含一下几个属性
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// 将该对象作为上下文去执行要 `modules[moduleId]`方法
// ,方法里面会往 module.exports **对象里面挂载值**,也就是模拟模块的导出。
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports; / / 最后返回那个模块导出的内容
}
代码上我已经尽量注释了,moduleId
就是 install
那里 bind
绑定的参数(key),我们可以根据这个moduleId
去mylib.common.1.js
文件里面找到对应的函数。里面就要我们组件的实现,比如被转成了render
函数的模板代码、属性和生命周期等。
看最后module.exports
的挂载代码:
到这里,webpack
是怎么将拆分后的代码结合的,就梳理清楚了。到这里,组件的代码终于是注册到了Vue
里面去,当Vue
渲染到相应的组件时,就会去执行这一套逻辑,加载组件了。那么Vue
是怎么处理注册的组件为Promise
函数的呢?也就是异步加载组件的呢?
Vue.js 技术揭秘里面有足够清晰的分析,首先在这里感谢作者。接下来的内容大部分内容来自《Vue.js 技术揭秘》了,建议有兴趣的同学去仔细阅读一遍。
Vue 对异步组件的处理
Vue
的异步组件的注册与普通的模式是不一样的,Vue 注册的组件不再是一个对象,而是一个工厂函数。注册的模式有三种:
// 1
Vue.component('async-example', function (resolve, reject) {
// 这个特殊的 require 语法告诉 webpack
// 自动将编译后的代码分割成不同的块,
// 这些块将通过 Ajax 请求自动下载。
require(['./my-async-component'], resolve)
})
// 2
Vue.component(
'async-webpack-example',
// 该 `import` 函数返回一个 `Promise` 对象。
() => import('./my-async-component')
)
// 3 称为高级模式,可以自定义一些东西
const AsyncComp = () => ({
// 需要加载的组件。应当是一个 Promise
component: import('./MyComp.vue'),
// 加载中应当渲染的组件
loading: LoadingComp,
// 出错时渲染的组件
error: ErrorComp,
// 渲染加载中组件前的等待时间。默认:200ms。
delay: 200,
// 最长等待时间。超出此时间则渲染错误组件。默认:Infinity
timeout: 3000
})
Vue.component('async-example', AsyncComp)
组件注册最终会走到Vue
源码中的createComponent
函数,(细节分析),我们看一下这个函数:
export function createComponent (
Ctor: Class<Component> | Function | Object | void,
data: ?VNodeData,
context: Component,
children: ?Array<VNode>,
tag?: string
): VNode | Array<VNode> | void {
if (isUndef(Ctor)) {
return
}
const baseCtor = context.$options._base
// plain options object: turn it into a constructor
if (isObject(Ctor)) {
Ctor = baseCtor.extend(Ctor)
}
// ...
// async component
let asyncFactory
if (isUndef(Ctor.cid)) {
asyncFactory = Ctor
Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
if (Ctor === undefined) {
// return a placeholder node for async component, which is rendered
// as a comment node but preserves all the raw information for the node.
// the information will be used for async server-rendering and hydration.
return createAsyncPlaceholder(
asyncFactory,
data,
context,
children,
tag
)
}
}
}
代码里面省略掉了普通组件注册的逻辑,这里只关注异步组件的注册逻辑。
由于我们这个时候传入的 Ctor
是一个函数,那么它也并不会执行 Vue.extend
逻辑,因此它的 cid
是 undefiend
,进入了异步组件创建的逻辑。这里首先执行了 Ctor = resolveAsyncComponent(asyncFactory, baseCtor, context)
方法,它的定义在 src/core/vdom/helpers/resolve-async-component.js
中,这函数的实现比较复杂:
export function resolveAsyncComponent (
factory: Function,
baseCtor: Class<Component>,
context: Component
): Class<Component> | void {
if (isTrue(factory.error) && isDef(factory.errorComp)) {
return factory.errorComp
}
if (isDef(factory.resolved)) {
return factory.resolved
}
if (isTrue(factory.loading) && isDef(factory.loadingComp)) {
return factory.loadingComp
}
if (isDef(factory.contexts)) {
// already pending
factory.contexts.push(context)
} else {
const contexts = factory.contexts = [context]
let sync = true
const forceRender = () => {
for (let i = 0, l = contexts.length; i < l; i++) {
contexts[i].$forceUpdate()
}
}
const resolve = once((res: Object | Class<Component>) => {
// cache resolved
factory.resolved = ensureCtor(res, baseCtor)
// invoke callbacks only if this is not a synchronous resolve
// (async resolves are shimmed as synchronous during SSR)
if (!sync) {
forceRender()
}
})
const reject = once(reason => {
process.env.NODE_ENV !== 'production' && warn(
`Failed to resolve async component: ${String(factory)}` +
(reason ? `\nReason: ${reason}` : '')
)
if (isDef(factory.errorComp)) {
factory.error = true
forceRender()
}
})
const res = factory(resolve, reject)
if (isObject(res)) {
if (typeof res.then === 'function') {
// () => Promise
if (isUndef(factory.resolved)) {
res.then(resolve, reject)
}
} else if (isDef(res.component) && typeof res.component.then === 'function') {
res.component.then(resolve, reject)
if (isDef(res.error)) {
factory.errorComp = ensureCtor(res.error, baseCtor)
}
if (isDef(res.loading)) {
factory.loadingComp = ensureCtor(res.loading, baseCtor)
if (res.delay === 0) {
factory.loading = true
} else {
setTimeout(() => {
if (isUndef(factory.resolved) && isUndef(factory.error)) {
factory.loading = true
forceRender()
}
}, res.delay || 200)
}
}
if (isDef(res.timeout)) {
setTimeout(() => {
if (isUndef(factory.resolved)) {
reject(
process.env.NODE_ENV !== 'production'
? `timeout (${res.timeout}ms)`
: null
)
}
}, res.timeout)
}
}
}
sync = false
// return in case resolved synchronously
return factory.loading
? factory.loadingComp
: factory.resolved
}
}
简单总结为如下几个步骤:
- 理了 3 种异步组件的创建方式,不同模式进入不同分支流程
- 判断传入的工厂函数的上下文,多个地方同时加载的时候,保证只会返回一个实例。
- 进入实际加载逻辑,定义了
forceRender
、resolve
和reject
函数 - 执行工程函数,并且传入
resolve
和reject
函数。 - 异步在未加载成功之前,先返回一个空的组件对象。这里是第一次渲染。
- 组件返回后,得到组件实例,然后调用
forceRender
函数强制刷新,这是第二次渲染。
步组件实现的本质是 2 次渲染,除了 0 delay 的高级异步组件第一次直接渲染成 loading 组件外,其它都是第一次渲染生成一个注释节点,当异步获取组件成功后,再通过 forceRender 强制重新渲染,这样就能正确渲染出我们异步加载的组件了。
总结
知道Vue
一直支持异步组件加载,通过这次分析,算是弄懂了实现的原理,与webpack
的结合还真的是巧妙。不过文章对Vue
具体是怎么实现异步加载组件的,只是简单的的说了一下逻辑步骤,webpack
是怎么打包成这个样子的,这里面的逻辑还是值得深究的。
参考文章 《Vue.js 技术揭秘》