由同步加载的打包我们发现将不同的打包进一个main.js文件。main.js会集中消耗太多网络资源,导致用户需要等待很久才可以开始与网页交互。
一般的解决方式是:根据需求降低首次加载文件的体积,在需要时(如切换前端路由器,交互事件回调)异步加载其他文件并使用其中的模块。
Webpack推荐用ES的import()规范来异步加载模块,我们根据ES规范修改一下入口模块的import方式,让其能够异步加载模块:
修改文件src/index.js:
console.log('Hello webpack!');window.setTimeout(() => {import('./utils/math').then(mathUtil => {console.log('1 + 2: ' + mathUtil.plus(1, 2));});}, 2000);
工具模块(src/utils/math.js)依然不变,在webpack配置里,我们指定一下资源文件的公共资源路径(publicPath),后面的探索过程中会遇到。
const path = require('path');module.exports = {mode: 'development',devtool: 'source-map',entry: './src/index.js',output: {path: path.resolve(__dirname, 'dist'),publicPath: '/dist/'}};
接着执行打包命令,打包之后出现了两个文件:

在dist文件夹中可以看到,除了main.js外,又多了一个0.js文件,./src/utils/math.js模块从main chunk迁移到了0 chunk中。而与同步加载代码不同的是,main chunk中添加了一些用于异步加载的代码,我们概览一下:
// webpackBootstrap(function(modules) {// 加载其他 chunk 成功后的 JSONP 回调函数function webpackJsonpCallback(data) {...}// 模块缓存对象var installedModules = {};// 记录正在加载和已经加载的 chunk 的对象,0表示已经加载成功// 1是当前模块的编号,已加载完成var installedChunks = {main: 0};// 拼接 chunk 的请求地址function jsonpScriptSrc(chunkId) {// ...}// require 函数function __webpack_require__(moduleId) {...}// 异步加载 chunk,通过动态添加 script 标签实现,返回封装加载过程的 promise__webpack_require__.e = function requireEnsure(chunkId) {...}// 根据配置文件确定的 publicPath__webpack_require__.p = "/dist/";.../**** JSONP 初始化 ****/var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);jsonpArray.push = webpackJsonpCallback;jsonpArray = jsonpArray.slice();for (var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);var parentJsonpFunction = oldJsonpFunction;/**** JSONP 初始化 ****/// 加载入口模块return __webpack_require__(__webpack_require__.s = "./src/index.js");})({"./src/index.js": (function(module, exports, __webpack_require__) {document.write('Hello webpack!\n');window.setTimeout(() => {__webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, /*! ./utils/math */ "./src/utils/math.js")).then(mathUtil => {console.log('1 + 2: ' + mathUtil.plus(1, 2));});}, 2000);})})
可以看到**webpackBootstrap**的函数体部分增加了一些内容,参数部分移除了**./src/utils/math.js**模块。
我们可以通俗的理解为webpack内置了对import()语句的支持,当webpack遇到了import()语句时会这样处理:
- 首先,以
./src/utils/math.js为入口新生成一个Chunk文件:0.js; - 然后,将
import()语句转换为__webpack_require__.e /* import() */(0)语句。当代码执行到import()语句时,就相当于执行了__webpack_require__.e /* import() */(0)语句。
异步加载的核心就是__webpack_require__.e()方法:
__webpack_require__.e = function requireEnsure(chunkId) {var promises = [];// JSONP chunk loading for javascriptvar 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 cachevar promise = new Promise(function(resolve, reject) {installedChunkData = installedChunks[chunkId] = [resolve, reject];});promises.push(installedChunkData[2] = promise);// start chunk loadingvar head = document.getElementsByTagName('head')[0];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);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;var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');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;head.appendChild(script);}}return Promise.all(promises);};
代码大致逻辑如下:
- 缓存查找:从缓存
installedChunks中查找是否有缓存模块,如果缓存标识为0,则表示模块已加载过,直接返回promise;如果缓存为数组,表示缓存正在加载中,则返回缓存的promise对象。 - 如果没有缓存,则创建一个
promise,并将promise和resolve、reject缓存在installedChunks中。 - 构建一个
script标签,append到head标签中,src指向加载的模块脚本资源,实现动态加载js脚本。 - 添加
script标签onload、onerror事件,如果超时或者模块加载失败,则会调用reject返回模块加载失败异常。 - 如果模块加载成功,则返回当前模块
promise,所以import()返回一个Promise,当文件加载成功时可以在Promise的then方法中获取到logger.js导出的内容。
在使用
import()分割代码后,你的浏览器要支持Promise API才能让代码正常运行,因为import()返回一个Promise,它依赖Promise。对于不原生支持Promise的浏览器,你可以注入Promise polyfill。
当script加载完成chunk文件,就开始执行模块代码了,0.js中的代码为:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0], {"./src/utils/math.js":(function (module, __webpack_exports__, __webpack_require__) {"use strict";__webpack_require__.r(__webpack_exports__);/* harmony export (binding) */__webpack_require__.d(__webpack_exports__, "plus", function () {return plus;});const plus = (a, b) => {return a + b;};})}]);
这段代码开始执行,把异步加载相关的chunk id与模块传给push函数。而前面已经提到过,window[“webpackJsonp”]数组的push函数已被重写为webpackJsonpCallback函数。
代码非常好理解,加载成功后立即调用webpackJsonp方法,将chunkId和模块内容传入。这里要分清2个概念,一个是chunkId,一个是moduleId。这个chunk的chunkId是0,则里面只包含一个module;moduleId是1,则一个chunk里面可以包含多个module。
其实这里的
webpackJsonp类似于jsonp中的callback,作用是作为模块加载和执行完成的回调,从而触发import的resolve。
具体细看webpackJsonpCallback代码来分析:
function webpackJsonpCallback(data) {var chunkIds = data[0];var moreModules = data[1];// then flag all "chunkIds" as loaded and fire callbackvar moduleId, chunkId, i = 0, resolves = [];// 将 chunk 标记为已加载for(;i < chunkIds.length; i++) {chunkId = chunkIds[i];if(installedChunks[chunkId]) {resolves.push(installedChunks[chunkId][0]);}installedChunks[chunkId] = 0;}// 把 "moreModules" 加到 webpackBootstrap 中的 modules 闭包变量中。for(moduleId in moreModules) {if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {modules[moduleId] = moreModules[moduleId];}}// parentJsonpFunction 是 window["webpackJsonp"] 的原生 push// 将 data 加入全局数组,缓存 chunk 内容if(parentJsonpFunction) parentJsonpFunction(data);// 执行 resolve 后,加载 chunk 的 promise 状态变为 resolved,then 内的函数开始执行。while(resolves.length) {resolves.shift()();}};
走进这个函数中,意味着异步加载的chunk内容已经拿到,这个时候我们要完成两件事,一是让依赖这次异步加载结果的模块继续执行,二是缓存加载结果。
这里我们先只讨论第一点,我们回忆一下之前__webpack_require__.e的内容,此时chunk还处于「加载中」的状态,也就是说对应的installedChunks[chunkId]的值此时为[resolve, reject, promise]。而进入到这个函数,说明chunk已经加载,但promise还未决议,于是webpackJsonpCallback内部定义了一个resolves变量用来收集installedChunks上的resolve并执行它。
代码中
installedChunks用来记录用户异步模块加载状态的对象。当chunk加载完成后,对应chunkId的值是0。在加载过程中,对应的值是一个数组,数组内保存了promise的相关信息。
等执行完chunk中的moreModules被合入入口文件的modules中的操作,执行resolve,这样就可以供下一个微任务中的 __webpack_require__同步加载模块。
我们看webpackBootstrap的参数部分:
({"./src/index.js":(function (module, exports, __webpack_require__) {console.log('Hello webpack!');window.setTimeout(() => {__webpack_require__.e(/*! import() */ 0).then(__webpack_require__.bind(null, "./src/utils/math.js")).then(mathUtil => {console.log('1 + 2: ' + mathUtil.plus(1, 2));});}, 2000);})});
__webpack_require__.e(/*! import() */ 0)返回promise决议后,__webpack_require__.bind(null, "./src/utils/math.js") 可以加载到chunk携带的模块,并返回模块作为下一个微任务函数的入参,接下来就是Webpack Loader翻译过的其他业务代码了。
所以webpack异步加载的原理是:
- 当
webpack打包遇到import()语句时,以参数文件为入口新生成一个chunk文件(而不会打包到一个文件中),文件内容是一个window['webpackJsonp'].webpackJsonpCallback()调用。 - 同时将
import()语句转换成__webpack_require__.e()语句。webpack启动代码中会增加__webpack_require__.e函数的定义,以及在window['webpackJsonp']下面挂载webpackJsonpCallback函数的定义。 - 当执行到
import()语句时,就相当于执行__webpack_require__.e()语句,会动态创建一个script标签来实现代码的异步加载,同时对应的chunkId的promise为pending状态。 script资源加载完毕,将异步模块代码加入到modules参数数组中,同时将对应模块的promise决议后,标记chunk完成。最后在下一个微任务中__webapck_require__可以加载到异步模块代码,继续执行。
执行流程如下图所示:

