webpack模塊加載機制
前端項目的規模越來越龐大,模塊化開發已經是普遍需求。早期的打包工具將所有模塊化的代碼打包到一個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提供了綜合各種模塊化標準的機制,讓我們能夠平穩的過渡。