前端项目的规模越来越庞大,模块化开发已经是普遍需求。早期的打包工具将所有模块化的代码打包到一个bundle文件中,在一个简单的html文件中引入脚本。webpack允许输出为多个bundle文件,从而实现按需加载,更好的利用浏览器缓存,提升用户体验。
这里不讨论如何配置webpack,只说webpack如何加载模块。考虑一个多页面程序,有page1和page2两个页面,都用到一个工具模块util,page1直接引用util,而page2在页面加载后满足一定条件时动态加载util。
//util.js
console.log('util')
export function log(arg) {
console.log(arg)
}
//page1.js
import { log } from './util'
//...
log('page1')
//page2.js
//...
//条件满足时动态加载util
import('./util').then((util) => {
util.log('page2')
})
首先明确webpack中的两个概念:
- module:一般是源代码中的一个文件,打包后被包裹在一个函数中,webpack模拟了模块环境
- chunk:一个chunk即一个输出bundle文件,不考虑其它分割规则,每个入口将产出一个chunk。一个chunk包含若干module
假设我们已经配置好webpack,让它输出三个chunk:
0.js //util.js
page1.js //页面1
page2.js //页面2
下面从webpack的打包输出来分析webpack模块加载机制。
静态依赖
webpack中,一个js源文件作为一个模块(也可以是其他文件)。webpack将模块代码包裹在一个函数中,返回模块的导出内容。注意,每个模块最多仅被执行一次,第二次请求时会直接从缓存返回。
(打包后的)page1.js:
//立即执行函数初始化webpack运行时,然后加载入口模块
(function (modules) {
//modules是被打包到page1.js这个文件(chunk)中的模块列表,此处有page1和util两个模块
//已加载的模块的缓存
var installedModules = {};
// webpack require函数,用来加载模块
function __webpack_require__(moduleId) {
//检查模块是否已装载,是则返回缓存的模块
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
//将模块添加到缓存
var module = installedModules[moduleId] = {
i: moduleId, //模块ID
l: false, //模块是否已装载
exports: {} //模块的导出内容
};
// 执行模块初始化
modules[moduleId].call(
module.exports, //将模块代码的this指向其自己的exports
module, //相当于commonjs中的module,对模块自身的引用
module.exports, //相当于commonjs中的exports,模块的导出内容
__webpack_require__ //相当于commonjs中的require,提供加载同一chunk中其他模块的方法
);
// 将模块标识为已加载
module.l = true;
// 返回模块的导出内容
return module.exports;
}
//加载入口模块,即我们的page1.js
return __webpack_require__(__webpack_require__.s = "./src/page1.js");
})({
//page1模块
"./src/page1.js":
function (module, __webpack_exports__, __webpack_require__) {
let util = __webpack_require__('./src/util.js')
util.log('page1')
},
//util模块
"./src/util.js":
function (module, __webpack_exports__, __webpack_require__) {
function log (arg) {
console.log(arg)
}
//util模块导出log函数
__webpack_exports__['log'] = log
}
});
代码被我简化了一下,webpack有很多兼容性和安全性方面的考虑,我将那部分修改了让代码看起来清晰。
可以看到,util和page1被函数包围,模拟模块环境,然后作为参数传递给立即执行函数。注意,模块只有被加载时才会执行内部代码。
在立即执行函数中,先进行了webpack初始化,即提供核心的模块加载函数__webpack_require__函数,并在函数上配置一些静态字段记录必要的信息(如__webpack_require__.s为入口模块ID),然后装入入口模块,开始执行我们自己的逻辑代码。
按需加载
在page2中我们使用了动态import()方法来加载util模块。webpack足够智能,无需配置它也聪明的自动将util独立到单独的文件(chunk),在需要时再进行请求。
webpack通过jsonp机制加载不同chunk中的模块。
page2.js:
(function(modules) {
//模块加载函数,同page1。注意,此函数仅用于加载已被
function __webpack_require__(moduleId) {
//......
}
//可用的模块列表,包含本chunk中的模块和已经请求完毕的,来自其他chunk的模块
__webpack_require__.m = modules;
//基路径,用于拼接其他chunk的url
__webpack_require__.p = "";
//已装载的模块缓存,注意区分__webpack_require__.m,前者应该是后者的子集
var installedModules = {};
//这是相对page1中多出来的东西,用来记录其他chunk的加载情况
//值可能有3种:
// undefined, 即该chunk还没有被请求过
// 0, 该chunk中的模块已经正确的添加到__webpack_require__.m中,可以通过__webpack_require__加载了
// [resolve, reject, promise], chunk正在请求中。promise在chunk请求结束后才被resolve或reject
var installedChunks = {
"page2": 0 //page2即页面2的chunk自身,当然已经加载完成
};
//jsonp函数,chunk请求成功时由被请求的chunk调用。它的任务为将chunk中的模块加入可用模块列表
//data参数由被请求的chunk传过来,包含模块信息。这也是jsonp的核心部分
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]) {
resolves.push(installedChunks[chunkId][0]);
}
//表示chunk已经加载好啦,下次不用去请求了
installedChunks[chunkId] = 0;
}
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
//把chunk中的模块们加入可用列表(__webpack_require__.m == modules √)
modules[moduleId] = moreModules[moduleId];
}
}
//原数组的push函数,详见后文
if(parentJsonpFunction) parentJsonpFunction(data);
//resolve请求时创建的promise,执行回调
while(resolves.length) {
resolves.shift()();
}
};
//异步请求chunk
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
//检查chunk是否已经加载。0表示已加载
if(installedChunkData !== 0) {
//正在加载...有点不清楚为啥要重复记录一次promise?
if(installedChunkData) {
//installedChunkData[2]是之前请求的promise
promises.push(installedChunkData[2]);
} else {
//没有请求过,开始请求chunk
//标识chunkId这个chunk正在请求,并记录promise相关信息
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
//现在installedChunks[chunkId] = [resolve, reject, promise]了
//用script标签加载chunk
var script = document.createElement('script');
var onScriptComplete;
script.charset = 'utf-8';
script.timeout = 120;
script.src = jsonpScriptSrc(chunkId);
//...请求错误处理...省略
//...超时处理
var timeout = setTimeout(function(){
}, 120000);
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
//拼接被请求chunk的url
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".js"
}
//通过window.webpackJsonp数组记录加载成功的chunk数据。其他chunk被加载后会将自己的chunk数据push到这个数组
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
//注意,webpack覆盖了window.webpackJsonp数组的push函数。将原生的push函数替换成了webpackJsonpCallback
//在webpackJsonpCallback函数中我们看到有调用parentJsonpFunction,其实那个函数才是原来的数组的push函数
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
//理论上说这时jsonpArray应该是空的。。但如果不是空的就手动调用下webpackJsonpCallback装载chunk
//window["webpackJsonp"]已经被webpack污染啦,所以它复制了一下?
jsonpArray = jsonpArray.slice();
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
var parentJsonpFunction = oldJsonpFunction;
//加载入口模块
return __webpack_require__(__webpack_require__.s = "./src/page2.js");
})
({
//页面2
"./src/page2.js":
(function(module, exports, __webpack_require__) {
//我们的动态import()被webpack转化后大致代码
//先请求包含util的chunk
__webpack_require__.e(0)
//请求成功之后通过__webpack_require__加载util模块
.then(__webpack_require__.bind(null, 0))
//执行我们自己的逻辑代码
.then((util) => {
util.log('page2')
})
})
});
代码仍然有点长,不过已经删除和修改了很多边缘代码,并且调整了一下顺序,结构比较清晰了。代码中的注释解释了整个模块加载过程。先通过jsonp请求得到chunk数据,然后缓存chunk中的模块到可用列表(未装载),这时同步的模块加载已经可用,直接调用__webpack_require__加载相关模块,步骤就和page1一样了。
被加载的chunk结构就很简单了,只是简单的调用jsonp函数传入模块数据。
//0.js
//注意,前面说了,window["webpackJsonp"]数组的push函数被webpack重写了
//所以实际上这里调用了主模块那边定义的webpackJsonpCallback函数
//一个chunk可能包含多个模块,所以参数为 [moduleIds],moreModules
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[0], {
"./src/util.js":
(function (module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_exports__['log'] = function (arg) {
console.log(arg)
}
})
}]);
总结
当项目规模增长到一定程度,模块化已是刚需。在ESModule标准制定之前广泛使用的模块化标准有commonjs,AMD,以及综合两者的UMD等。在ES6中模块化终于得到标准的支持,然而各平台对其的支持有限,暂时还难以替代传统解决方案。万幸,webpack提供了综合各种模块化标准的机制,让我们能够平稳的过渡。