简介
在大型应用里,有些组件可能一开始并不显示,只有在特定条件下才会渲染,那么这种情况下该组件的资源其实不需要一开始就加载,完全可以在需要的时候再去请求,这也可以减少页面首次加载的资源体积,要在Vue
中使用异步组件也很简单:
// AsyncComponent.vue <template> <div>我是异步组件的内容</div> </template> <script> export default { name: 'AsyncComponent' } </script>
// App.vue <template> <div id="app"> <AsyncComponent v-if="show"></AsyncComponent> <button @click="load">加载</button> </div> </template> <script> export default { name: 'App', components: { AsyncComponent: () => import('./AsyncComponent'), }, data() { return { show: false, } }, methods: { load() { this.show = true }, }, } </script>
我们没有直接引入AsyncComponent
组件进行注册,而是使用import()
方法来动态的加载,import
()
是ES2015 Loader 规范 定义的一个方法,webpack
内置支持,会把AsyncComponent
组件的内容单独打成一个js
文件,页面初始不会加载,点击加载按钮后才会去请求,该方法会返回一个promise
,接下来,我们从源码角度详细看看这一过程。
通过本文,你可以了解Vue
对于异步组件的处理过程以及webpack
的资源加载过程。
编译产物
首先我们打个包,生成了三个js
文件:
第一个文件是我们应用的入口文件,里面包含了main.js
、App.vue
的内容,另外还包含了一些webpack
注入的方法,第二个文件就是我们的异步组件AsyncComponent
的内容,第三个文件是其他一些公共库的内容,比如Vue
。
然后我们看看App.vue
编译后的内容:
上图为App
组件的选项对象,可以看到异步组件的注册方式,是一个函数。
上图是App.vue
模板部分编译后的渲染函数,当_vm.show
为true
的时候,会执行_c('AsyncComponent')
,否则执行_vm._e()
,创建一个空的VNode
,_c
即createElement
方法:
vm._c = function (a, b, c, d) { return createElement(vm, a, b, c, d, false); };
接下来看看当我们点击按钮后,这个方法的执行过程。
createElement方法
function createElement ( context, tag, data, children, normalizationType, alwaysNormalize ) { if (Array.isArray(data) || isPrimitive(data)) { normalizationType = children; children = data; data = undefined; } if (isTrue(alwaysNormalize)) { normalizationType = ALWAYS_NORMALIZE; } return _createElement(context, tag, data, children, normalizationType) }
context
为App
组件实例,tag
就是_c
的参数AsyncComponent
,其他几个参数都为undefined
或false
,所以这个方法的两个if
分支都没走,直接进入_createElement
方法:
function _createElement ( context, tag, data, children, normalizationType ) { // 如果data是被观察过的数据 if (isDef(data) && isDef((data).__ob__)) { return createEmptyVNode() } // v-bind中的对象语法 if (isDef(data) && isDef(data.is)) { tag = data.is; } // tag不存在,可能是component组件的:is属性未设置 if (!tag) { return createEmptyVNode() } // 支持单个函数项作为默认作用域插槽 if (Array.isArray(children) && typeof children[0] === 'function' ) { data = data || {}; data.scopedSlots = { default: children[0] }; children.length = 0; } // 处理子节点 if (normalizationType === ALWAYS_NORMALIZE) { children = normalizeChildren(children); } else if (normalizationType === SIMPLE_NORMALIZE) { children = simpleNormalizeChildren(children); } // ... }
上述逻辑在我们的示例中都不会进入,接着往下看:
function _createElement ( context, tag, data, children, normalizationType ) { // ... var vnode, ns; // tag是字符串 if (typeof tag === 'string') { var Ctor; ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag); if (config.isReservedTag(tag)) { // 是否是保留元素,比如html元素或svg元素 if (false) {} vnode = new VNode( config.parsePlatformTagName(tag), data, children, undefined, undefined, context ); } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) { // 组件 vnode = createComponent(Ctor, data, context, children, tag); } else { // 其他未知标签 vnode = new VNode( tag, data, children, undefined, undefined, context ); } } else { // tag是组件选项或构造函数 vnode = createComponent(tag, data, context, children); } // ... }
对于我们的异步组件,tag
为AsyncComponent
,是个字符串,另外通过resolveAsset
方法能找到我们注册的AsyncComponent
组件:
function resolveAsset ( options,// App组件实例的$options type,// components id, warnMissing ) { if (typeof id !== 'string') { return } var assets = options[type]; // 首先检查本地注册 if (hasOwn(assets, id)) { return assets[id] } var camelizedId = camelize(id); if (hasOwn(assets, camelizedId)) { return assets[camelizedId] } var PascalCaseId = capitalize(camelizedId); if (hasOwn(assets, PascalCaseId)) { return assets[PascalCaseId] } // 本地没有,则在原型链上查找 var res = assets[id] || assets[camelizedId] || assets[PascalCaseId]; if (false) {} return res }
Vue
会把我们的每个组件都先创建成一个构造函数,然后再进行实例化,在创建过程中会进行选项合并,也就是把该组件的选项和父构造函数的选项进行合并:
上图中,子选项是App
的组件选项,父选项是Vue
构造函数的选项对象,对于components
选项,会以父类的该选项值为原型创建一个对象,然后把子类本身的选项值作为属性添加到该对象上,最后这个对象作为子类构造函数的options.components
的属性值:
然后在组件实例化时,会以构造函数的options
对象作为原型创建一个对象,作为实例的$options
:
所以App
实例能通过$options
从它的构造函数的options.components
对象上找到AsyncComponent
组件:
可以发现就是我们前面看到过的编译后的函数。
接下来会执行createComponent
方法:
function createComponent ( Ctor, data, context, children, tag ) { // ... // 异步组件 var asyncFactory; if (isUndef(Ctor.cid)) { asyncFactory = Ctor; Ctor = resolveAsyncComponent(asyncFactory, baseCtor); if (Ctor === undefined) { return createAsyncPlaceholder( asyncFactory, data, context, children, tag ) } } // ... }
接着又执行了resolveAsyncComponent
方法:
function resolveAsyncComponent ( factory, baseCtor ) { // ... var owner = currentRenderingInstance; if (owner && !isDef(factory.owners)) { var owners = factory.owners = [owner]; var sync = true; var timerLoading = null; var timerTimeout = null ;(owner).$on('hook:destroyed', function () { return remove(owners, owner); }); var forceRender = function(){} var resolve = once(function(){}) var reject = once(function(){}) // 执行异步组件的函数 var res = factory(resolve, reject); } // ... }
到这里终于执行了异步组件的函数,也就是下面这个:
function AsyncComponent() { return __webpack_require__.e( /*! import() */ "chunk-1f79b58b").then(__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d")); }
欲知res
是什么,我们就得看看这几个webpack
的函数是干什么的。
加载组件资源
webpack_require.e方法
先看__webpack_require__.e
方法:
__webpack_require__.e = function requireEnsure(chunkId) { var promises = []; // 已经加载的chunk var installedChunkData = installedChunks[chunkId]; if (installedChunkData !== 0) { // 0代表已经加载 // 值非0即代表组件正在加载中,installedChunkData[2]为promise对象 if (installedChunkData) { promises.push(installedChunkData[2]); } else { // 创建一个promise,并且把两个回调参数缓存到installedChunks对象上 var promise = new Promise(function (resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); // 把promise对象本身也添加到缓存数组里 promises.push(installedChunkData[2] = promise); // 开始发起chunk请求 var script = document.createElement('script'); var onScriptComplete; script.charset = 'utf-8'; script.timeout = 120; // 拼接chunk的请求url script.src = jsonpScriptSrc(chunkId); var error = new Error(); // chunk加载完成/失败的回到 onScriptComplete = function (event) { script.onerror = script.onload = null; clearTimeout(timeout); var chunk = installedChunks[chunkId]; if (chunk !== 0) { // 如果installedChunks对象上该chunkId的值还存在则代表加载出错了 if (chunk) { var errorType = event && (event.type === 'load' ? 'missing' : event.type); var realSrc = event && event.target && event.target.src; error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')'; error.name = 'ChunkLoadError'; error.type = errorType; error.request = realSrc; chunk[1](error); } installedChunks[chunkId] = undefined; } }; // 设置超时时间 var timeout = setTimeout(function () { onScriptComplete({ type: 'timeout', target: script }); }, 120000); script.onerror = script.onload = onScriptComplete; document.head.appendChild(script); } } return Promise.all(promises); };
这个方法虽然有点长,但是逻辑很简单,首先函数返回的是一个promise
,如果要加载的chunk
未加载过,那么就创建一个promise
,然后缓存到installedChunks
对象上,接下来创建script
标签来加载chunk
,唯一不好理解的是onScriptComplete
函数,因为在这里面判断该chunk
在installedChunks
上的缓存信息不为0
则当做失败处理了,问题是前面才把promise
信息缓存过去,也没有看到哪里有进行修改,要理解这个就需要看看我们要加载的chunk
的内容了:
可以看到代码直接执行了,并往webpackJsonp
数组里添加了一项:
window["webpackJsonp"] = window["webpackJsonp"] || []).push([["chunk-1f79b58b"],{..}])
看着似乎也没啥问题,其实window["webpackJsonp"]
的push
方法被修改过了:
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); jsonpArray.push = webpackJsonpCallback; var parentJsonpFunction = oldJsonpFunction;
被修改成了webpackJsonpCallback
方法:
function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; var moduleId, chunkId, i = 0, resolves = []; for (; i < chunkIds.length; i++) { chunkId = chunkIds[i]; if (Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) { // 把该chunk的promise的resolve回调方法添加到resolves数组里 resolves.push(installedChunks[chunkId][0]); } // 标记该chunk已经加载完成 installedChunks[chunkId] = 0; } // 将该chunk的module数据添加到modules对象上 for (moduleId in moreModules) { if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } } // 执行原本的push方法 if (parentJsonpFunction) parentJsonpFunction(data); // 执行resolve函数 while (resolves.length) { resolves.shift()(); } }
这个函数会取出该chunk
加载的promise
的resolve
函数,然后将它在installedChunks
上的信息标记为0
,代表加载成功,所以在后面执行的onScriptComplete
函数就可以通过是否为0
来判断是否加载失败。最后会执行resolve
函数,这样前面__webpack_require__.e
函数返回的promise
状态就会变为成功。
让我们再回顾一下AsyncComponent
组件的函数:
function AsyncComponent() { return __webpack_require__.e( /*! import() */ "chunk-1f79b58b").then(__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d")); }
chunk
加载完成后会执行__webpack_require__
方法。
__webpack_require__
方法
这个方法是webpack
最重要的方法,用来加载模块:
function __webpack_require__(moduleId) { // 检查模块是否已经加载过了 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 创建一个新模块,并缓存 var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; // 执行模块函数 modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // 标记模块加载状态 module.l = true; // 返回模块的导出 return module.exports; }
所以上面的__webpack_require__.bind(null, /*! ./AsyncComponent */ "c61d")
其实是去加载了c61d
模块,这个模块就在我们刚刚请求回来的chunk
里:
这个模块内部又会去加载它依赖的模块,最终返回的结果为:
其实就是AsyncComponent
的组件选项。
回到createElement方法
回到前面的resolveAsyncComponent
方法:
var res = factory(resolve, reject);
现在我们知道这个res
其实就是一个未完成的promise
,Vue
并没有等待异步组件加载完成,而是继续向后执行:
if (isObject(res)) { if (isPromise(res)) { // () => Promise if (isUndef(factory.resolved)) { res.then(resolve, reject); } } } return factory.resolved
把定义的resolve
和reject
函数作为参数传给promise
res
,最后返回了factory.resolved
,这个属性并没有被设置任何值,所以是undefined
。
接下来回到createComponent
方法:
Ctor = resolveAsyncComponent(asyncFactory, baseCtor); if (Ctor === undefined) { // 返回异步组件的占位符节点,该节点呈现为注释节点,但保留该节点的所有原始信息。 // 这些信息将用于异步服务端渲染。 return createAsyncPlaceholder( asyncFactory, data, context, children, tag ) }
因为Ctor
是undefined
,所以会执行createAsyncPlaceholder
方法返回一个占位符节点:
function createAsyncPlaceholder ( factory, data, context, children, tag ) { // 创建一个空的VNode,其实就是注释节点 var node = createEmptyVNode(); // 保留组件的相关信息 node.asyncFactory = factory; node.asyncMeta = { data: data, context: context, children: children, tag: tag }; return node }
最后让我们再回到_createElement
方法:
// ... vnode = createComponent(Ctor, data, context, children, tag); // ... return vnode
很简单,对于异步节点,直接返回创建的注释节点,最后把虚拟节点转换成真实节点,会实际创建一个注释节点:
现在让我们来看看resolveAsyncComponent
函数里面定义的resolve
,也就是当chunk
加载完成后会执行的:
var resolve = once(function (res) {d // 缓存结果 factory.resolved = ensureCtor(res, baseCtor); // 非同步解析时调用 // (SSR会把异步解析为同步) if (!sync) { forceRender(true); } else { owners.length = 0; } });
res
即AsyncComponent
的组件选项,baseCtor
为Vue
构造函数,会把它们作为参数调用ensureCtor
方法:
function ensureCtor (comp, base) { if ( comp.__esModule || (hasSymbol && comp[Symbol.toStringTag] === 'Module') ) { comp = comp.default; } return isObject(comp) ? base.extend(comp) : comp }
可以看到实际上是调用了extend
方法:
前面也提到过,Vue
会把我们的组件都创建一个对应的构造函数,就是通过这个方法,这个方法会以baseCtor
为父类创建一个子类,这里就会创建AsyncComponent
子类:
子类创建成功后会执行forceRender
方法:
var forceRender = function (renderCompleted) { for (var i = 0, l = owners.length; i < l; i++) { (owners[i]).$forceUpdate(); } if (renderCompleted) { owners.length = 0; if (timerLoading !== null) { clearTimeout(timerLoading); timerLoading = null; } if (timerTimeout !== null) { clearTimeout(timerTimeout); timerTimeout = null; } } };
owners
里包含着App
组件实例,所以会调用它的$forceUpdate
方法,这个方法会迫使 Vue
实例重新渲染,也就是重新执行渲染函数,进行虚拟DOM
的diff
和path
更新。
所以会重新执行App
组件的渲染函数,那么又会执行前面的createElement
方法,又会走一遍我们前面提到的那些过程,只是此时AsyncComponent
组件已经加载成功并创建了对应的构造函数,所以对于createComponent
方法,这次执行resolveAsyncComponent
方法的结果不再是undefined
,而是AsyncComp
onent
组件的构造函数:
Ctor = resolveAsyncComponent(asyncFactory, baseCtor); function resolveAsyncComponent ( factory, baseCtor ) { if (isDef(factory.resolved)) { return factory.resolved } }
接下来就会走正常的组件渲染逻辑:
var name = Ctor.options.name || tag; var vnode = new VNode( ("vue-component-" + (Ctor.cid) + (name ? ("-" + name) : '')), data, undefined, undefined, undefined, context, { Ctor: Ctor, propsData: propsData, listeners: listeners, tag: tag, children: children }, asyncFactory ); return vnode
可以看到对于组件其实也是创建了一个VNode
,具体怎么把该组件的VNode
渲染成真实DOM
不是本文的重点就不介绍了,大致就是在虚拟DOM
的diff
和patch
过程中如果遇到的VNode
是组件类型,那么会new
一个该组件的实例关联到VNode
上,组件实例化和我们new Vue()
没有什么区别,都会先进行选项合并、初始化生命周期、初始化事件、数据观察等操作,然后执行该组件的渲染函数,生成该组件的VNode
,最后进行patch
操作,生成实际的DOM
节点,子组件的这些操作全部完成后才会再回到父组件的diff
和patch
过程,因为子组件的DOM
已经创建好了,所以插入即可,更详细的过程有兴趣可自行了解。
以上就是本文全部内容。