webpack模块异步加载原理浅析

2,599 阅读5分钟

前言

在做首屏性能优化的时候,我们针对一些首屏使用不到的资源,通常采用异步组件。只在需要的时候才从服务器加载一个模块。Vue 允许我们以一个工厂函数的方式定义我们的组件,这个工厂函数会异步解析我们的组件。Vue 只有在这个组件需要被渲染的时候才会触发该工厂函数,并且会把结果缓存起来供重新渲染。通常我们将异步组件和 webpackcode-splitting 功能一起配合使用。今天我们就来看看,动态引入的方式和实现原理。

组件的动态导入方式

小小的另一篇文章 【vue系列】当 element-ui 按需引入遇到 vue-router 路由懒加载有讲过,我们再来重温一遍。看看是哪三种模式。

方式一:ES提案的动态import

目前比较推崇的方式,将异步组件定义为返回一个 Promise 的工厂函数 (该函数返回的 Promise 应该 resolve 组件本身),在 webpack 中,我们可以使用 动态 import 语法来定义代码分块。但是这种方式针对三方UI库,按需引入可能有问题。

// …

const App = () => import('./pages/app')
const Subtable = () => import('./pages/subtable')
const routes = [
    {
        path: '/',
        name: 'app',
        meta: {
            title: '下属报表'
        },
        component: App
    },
    {
        path: '/subtable',
        name: 'app',
        meta: {
            title: '下属统计表'
        },
        component: Subtable
    }
]
// …

方式二:Vue式异步加载组件 - AMD风格

通过 resolve => require([‘../path’], resolve) 的方式可以实现按需加载,并且一个组件会打包成一个js文件。

const App = resolve => require(['./pages/app'], resolve)
const Subtable = resolve => require(['./pages/subtable'], resolve)

方式三:webpack的require.ensuire() - CMD风格

通过 require.ensure() 实现按需加载,接收两个参数,第二个参数可以指定 chunkName

const App = r => require.ensure([], () => r(require('./pages/app')), 'app')
const Subtable = r => require.ensure([], () => r(require('./pages/subtable')), 'subtable')

小小的疑问是 webpack是如何实现异步加载的?,为了解决这个问题,小小基于 vue-cli 快速创建一个新项目webpack-dynamic-import,我们一起来看看。

异步加载的实现原理探寻

初始化 vue-cli 项目,动态引入组件

在 webpack-dynamic-import 项目里,我们修改 src>App.vue文件,将 import HelloWorld from './components/HelloWorld.vue' 修改为 const HelloWorld = () => import('./components/HelloWorld.vue') 动态组件引入。然后构建打包。

分析打包结果

先从 index.html 打包结果查看,在head标签里有几个以 link 标签引入的 js 文件,对应的rel属性,各不相同有的是 prefetch ,有的是 preload,还有的带有as属性。看到这里小小产生了疑问。

  • 为什么js文件要以link的形式引入?
  • prefetch 与 preload 的区别?
  • as=“script” 有什么作用?

想了解以上问题,先来看看MDN对 通过rel="preload"进行内容预加载 的介绍。

元素的rel 的属性值 preload 能够让我们在我们的 HTML 页面中 元素内部预加载一些资源。采用这一机制可以更早的得到加载并可用,减少对页面的初步渲染的阻塞。

通过 href 和 as 属性指定需要被预加载资源的资源路径和类型,这里 as=“script” 指名了文件的类型是 JavaScript文件,as支持的属性类型很多,具体可以到MDN查看。

<link rel="prefetch"> 预获取一些资源,比 preload 的优先级低。

<link href="/js/HelloWorld.js" rel="prefetch">
<link href="/js/app.js" rel="preload" as="script">
<link href="/js/chunk-vendors.js" rel="preload" as="script">

再来看代码,就能晓得 app.js和chunk-vendors.js 的优先级较高,采用资源预加载机制。HelloWorld.js 的优先级较低,是在我们点击按钮才会展现的一个组件文件。

继续往下看,在body标签的尾部通过script标签引入了两个js文件。

<script type="text/javascript" src="/js/chunk-vendors.js"></script>
<script type="text/javascript" src="/js/app.js"></script>

这里每个js文件的内容是什么,决定了它们的引入顺序。打包出来的 distindex.html 的link和script标签引入的文件为什么不同。link标签主要是用来做资源预加载的,而script是代码执行的。chunk-vendors.js 为提取的公共资源,需要优先加载和执行,app.js 为我们的主内容资源。

点击按钮,动态资源被加载

点击按钮,HelloWorld 动态组件资源被加载,执行,在app.js中有这样一段代码,将 HelloWorld 打包成一个js文件。

 return __webpack_require__.e(/*! import() | HelloWorld */ \"HelloWorld\").then(__webpack_require__.bind(null, /*! ./components/HelloWorld.vue */ \"./src/components/HelloWorld.vue\"));

返回一个 __webpack_require__.e 的执行结果,那是一个Promise的工厂函数。__webpack_require__.e 函数的定义我们找到了

/******/ 	__webpack_require__.e = function requireEnsure(chunkId) {
/******/ 		var promises = [];
/******/
/******/
/******/ 		// 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);
/******/
/******/ 				// start chunk loading
/******/ 				var script = document.createElement('script');
/******/ 				var onScriptComplete;
/******/
/******/ 				script.charset = 'utf-8';
/******/ 				script.timeout = 120;
/******/ 				if (__webpack_require__.nc) {
/******/ 					script.setAttribute("nonce", __webpack_require__.nc);
/******/ 				}
/******/ 				script.src = jsonpScriptSrc(chunkId);
/******/
/******/ 				// create error before stack unwound to get useful stacktrace later
/******/ 				var error = new Error();
/******/ 				onScriptComplete = function (event) {
/******/ 					// avoid mem leaks in IE.
/******/ 					script.onerror = script.onload = null;
/******/ 					clearTimeout(timeout);
/******/ 					var chunk = installedChunks[chunkId];
/******/ 					if(chunk !== 0) {
/******/ 						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);
/******/ 	};

这个函数先判断这个chunkId是否被加载过,没有话就去创建一个Promise,在Promise里面去创建一个script标签,然后设置src的路径,该路径就是被webpack代码拆分打包的组件代码路径。当代码被加载完成,通过webpackJsonpCallback执行。

/******/ 	function webpackJsonpCallback(data) {
/******/ 		var chunkIds = data[0];
/******/ 		var moreModules = data[1];
/******/ 		var executeModules = data[2];
/******/
/******/ 		// add "moreModules" to the modules object,
/******/ 		// then flag all "chunkIds" as loaded and fire callback
/******/ 		var moduleId, chunkId, i = 0, resolves = [];
/******/ 		for(;i < chunkIds.length; i++) {
/******/ 			chunkId = chunkIds[i];
/******/ 			if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) {
/******/ 				resolves.push(installedChunks[chunkId][0]);
/******/ 			}
/******/ 			installedChunks[chunkId] = 0;
/******/ 		}
/******/ 		for(moduleId in moreModules) {
/******/ 			if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
/******/ 				modules[moduleId] = moreModules[moduleId];
/******/ 			}
/******/ 		}
/******/ 		if(parentJsonpFunction) parentJsonpFunction(data);
/******/
/******/ 		while(resolves.length) {
/******/ 			resolves.shift()();
/******/ 		}
/******/
/******/ 		// add entry modules from loaded chunk to deferred list
/******/ 		deferredModules.push.apply(deferredModules, executeModules || []);
/******/
/******/ 		// run deferred modules when all chunks ready
/******/ 		return checkDeferredModules();
/******/ 	};

需要注意的是 resolves.push(installedChunks[chunkId][0]); 其中 installedChunks[chunkId][0] 就是Promise 的 resolve 函数。

在回到 HelloWorld.js 文件中:

(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["HelloWorld"],{4805:function(e,t,r){.../},)

当该文件加载完成,自执行,往 webpackJsonp 数组里添加了 chunkId4805 模块,4805就是这个异步组件的对应的map的key。

其基本原理,就是动态创建一个 script 引入动态资源文件,采用典型的 jsonp 形式执行callback。

  • __webpack_require__.e 函数,异步 import 的调用
  • webpackJsonpCallback 函数,加载异步模块完成的回调。
  • installedModules 作用,缓存已经加载过的 module。
  • installedChunks 作用,缓存已经加载过的 chunk 的状态。
  • jsonpScriptSrc 作用,publicPath+chunkId的方式获取到异步加载模块的url地址。