你真的瞭解前端模組化麼?
告別「webpack配置工程師」
webpack是一個強大而複雜的前端自動化工具。其中一個特點就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行?但是,難道你真的只滿足於玩轉webpack配置麼?
顯然不是。在學習如何使用webpack之外,我們更需要深入webpack內部,探索各部分的設計與實現。萬變不離其宗,即使有一天webpack“過氣”了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。因此,在學習webpack過程中,我會總結一系列【webpack進階】的文章和大家分享。
歡迎感興趣的同學多多交流與關注!
1. 引言
下面進入正題。一直以來,在前端領域,開發人員日益增長的語言能力需求和落後的JavaScript規範形成了一大矛盾。例如,我們會用babel來進行ES6到ES5的語法轉換,會使用各種polyfill來相容老式上的新特性……而我們本文的主角 —— 模組化也是如此。
由於JavaScript在設計之初就沒有考慮這一點,加之模組化規範的遲到,導致社群中湧現出一系列前端執行時的模組化方案,例如RequireJS、seaJS等。以及與之對應的編譯期模組依賴解決方案,例如browserify、rollup和本文的主角webpack。
但是我們要知道,<script type="module">
還存在一定的相容性與使用問題。
在更通用的範圍內來講,瀏覽器原生實際是不支援所謂的CommonJS或ESM模組化規範的。那麼webpack是如何在打包出的程式碼中實現模組化的呢?
2. NodeJS中的模組化
在探究webpack打包後程式碼的模組化實現前,我們先來看一下Node中的模組化。
NodeJS(以下簡稱為Node)在模組化上基本是遵循的CommonJS規範,而webpack打包出來的程式碼所實現模組化的方式,也類似於CommonJS。因此,我們先以熟悉的Node(這裡主要參考Node v10)作為引子,簡單介紹它的模組化實現,幫助我們接下來理解webpack的實現。
Node中的模組引入會經歷下面幾個步驟:
- 路徑分析
- 檔案定位
- 編譯執行
在Node中,模組以檔案維度存在,並且在編譯後快取於記憶體中,通過require.cache
可以檢視模組快取情況。在模組中新增console.log(require.cache)
檢視輸出如下:
{ '/Users/alienzhou/programming/gitrepo/test.js':
Module {
id: '.',
exports: {},
parent: null,
filename: '/Users/alienzhou/programming/gitrepo/test.js',
loaded: false,
children: [],
paths:
[ '/Users/alienzhou/programming/gitrepo/node_modules',
'/Users/alienzhou/programming/node_modules',
'/Users/alienzhou/node_modules',
'/Users/node_modules',
'/node_modules' ] } }
複製程式碼
上面就是模組物件的資料結構,也可以在Node原始碼中找到Module類的構造方法。其中exports
屬性非常重要,它就是模組的匯出物件。因此,下面這行語句
var test = require('./test.js');
複製程式碼
其實就是把test.js
模組的exports
屬性賦值給test
變數。
也許你還會好奇,當我們寫一個Node(JavaScript)模組時,模組裡的module
、require
、__filename
等這些變數是哪來的?如果你看過Node loader.js 部分原始碼,應該就大致能理解:
Module.wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
Module.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
複製程式碼
Node會自動將每個模組進行包裝(wrap),將其變為一個function。例如模組test.js原本為:
console.log(require.cache);
module.exports = 'test';
複製程式碼
包裝後大致會變為:
(function (exports, require, module, __filename, __dirname) {
console.log(require.cache);
module.exports = 'test';
});
複製程式碼
這下你應該明白module
、require
、__filename
這些變數都是哪來的了吧 —— 它們會被作為function的引數在模組編譯執行時注入進來。以一個副檔名為.js
的模組為例,當你require
它時,一個完整的方法呼叫大致包括下面幾個過程:
st=>start: require()引入模組
op1=>operation: 呼叫._load()載入模組
op2=>operation: new Module(filename, parent)建立模組物件
op3=>operation: 將模組物件存入快取
op4=>operation: 根據檔案型別呼叫Module._extensions
op5=>operation: 呼叫.compile()編譯執行js模組
cond=>condition: Module._cache是否無快取
e=>end: 返回module.exports結果
st->op1->cond
cond(yes)->op2->op3->op4->op5->e
cond(no)->e
複製程式碼
在Node原始碼中能看到,模組執行時,包裝定義的幾個變數被注入了:
if (inspectorWrapper) {
result = inspectorWrapper(compiledWrapper, this.exports, this.exports,
require, this, filename, dirname);
} else {
result = compiledWrapper.call(this.exports, this.exports, require, this,
filename, dirname);
}
複製程式碼
題外話,從這裡你也可以看出,在模組內使用
module.exports
與exports
的區別
3. webpack實現的前端模組化
之所以在介紹「webpack是如何在打包出的程式碼中實現模組化」之前,先用一定篇幅介紹了Node中的模組化,是因為兩者在同步依賴的設計與實現上有異曲同工之處。理解Node的模組化對學習webpack很有幫助。當然,由於執行環境的不同(webpack打包出的程式碼執行在客戶端,而Node是在服務端),實現上也有一定的差異。
下面就來看一下,webpack是如何在打包出的程式碼中實現前端(客戶端)模組化的。
3.1. 模組物件
和Node的模組化實現類似,在webpack打包出的程式碼中,每個模組也有一個對應的模組物件。在__webpack_require__()
方法中,有這麼一段程式碼:
function __webpack_require__(moduleId) {
// …… other code
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {},
parents: null,
children: []
};
// …… other code
}
複製程式碼
類似於Node,在webpack中各個模組的也有對應的模組物件,其資料結構基本遵循CommonJS規範;其中installedModules
則是模組快取物件,類似於Node中的require.cache
/Module._cache
。
2.2. 模組的require:__webpack_require__
__webpack_require__
是webpack前端執行時模組化中非常重要的一個方法,相當於CommonJS規範中的require
。
根據第一部分的流程圖:在Node中,當我們require
一個模組時,會先判斷該模組是否在快取之中,如果存在則直接返回該模組的exports
屬性;否則會載入並執行該模組。webpack中的實現也類似:
function __webpack_require__(moduleId) {
// 1.首先會檢查模組快取
if(installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// 2. 快取不存在時,建立並快取一個新的模組物件,類似Node中的new Module操作
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {},
children: []
};
// 3. 執行模組,類似於Node中的:
// result = compiledWrapper.call(this.exports, this.exports, require, this, filename, dirname);
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
module.l = true;
// 4. 返回該module的輸出
return module.exports;
}
複製程式碼
如果你仔細對比webpack與Node,你會發現在__webpack_require__
中有一個重要的區別:
在webpack中不存在像Node一樣呼叫._compile()
這種方法的過程。即不會像Node那樣,對一個未載入快取的模組,通過「讀取模組路徑 -> 編譯模組程式碼 -> 執行模組」來載入模組。為什麼呢?
這是因為,Node作為服務端語言,模組都是本地檔案,載入時延低,可同步阻塞進行模組檔案定址、讀取、編譯和執行,這些過程在模組require的時候再“按需”執行即可;而webpack執行在客戶端(瀏覽器),顯然不能在需要時(即執行__webpack_require__
時)再通過網路載入js檔案,並同步地等待載入完成後再返回__webpack_require__
。這種網路時延,顯然不能滿足“同步依賴”的要求。
那麼webpack是如何解決這個問題的呢?
3.2. 如何解決前端的同步依賴
我們還是回來看下Node:
Node(v10)中載入、編譯與執行(js)模組的程式碼主要集中在Module._extensions['.js']
和Module.prototype._compile
中。首先會通過fs.readFileSync
讀取檔案內容,然後通過vm.runInThisContext
來編譯和執行JavaScript程式碼。
The vm module provides APIs for compiling and running code within V8 Virtual Machine contexts.
但是,根據上面的分析,在前端runtime中肯定不能通過網路去同步獲取JavaScript指令碼檔案;那麼就需要我們換一個思路:有沒有什麼地方能夠預先放置我們“之後”可能會需要的模組,讓我們能夠在require時不需要同步等待過長的時間(當然,這裡的“之後”可能是幾秒、幾分鐘後,也可能是這次事件迴圈task的下幾行程式碼)。
記憶體就是一個不錯的選擇。我們可以把同步依賴的模組先“註冊”到記憶體中(模組暫存),等到require時,再執行該模組、快取模組物件、返回對應的exports
。而webpack中,這個所謂的記憶體就是modules
物件。
注意這裡指的模組暫存和模組快取概念完全不同。暫存可以粗略類比為將編譯好的模組程式碼先放到記憶體中,實際並沒有引入該模組。基於這個目的,我們也可以把“模組暫存”理解為“模組註冊”,因此後文中“模組暫存”與“模組註冊”具有相等的概念。
所以,過程大致是這樣的:
當我們已經獲取了模組內容後(但模組還未執行),我們就將其暫存在modules
物件中,鍵就是webpack的moduleId;等到需要使用__webpack_require__
引用模組時,發現快取中沒有,則從modules
物件中取出暫存的模組並執行。
3.3. 如何”暫存“模組
思路已經清晰了,那麼我們就來看看,webpack是如何將模組“暫存”在modules
物件上的。在實際上,webpack打包出來的程式碼可以簡單分為兩類:
- 一類是webpack模組化的前端runtime,你可以簡單類比為RequireJS這樣的前端模組化類庫所實現的功能。它會控制模組的載入、快取,提供諸如
__webpack_require__
這樣的require方法等。 - 另一類則是模組註冊與執行的程式碼,包含了原始碼中的模組程式碼。為了進一步理解,我們先來看一下這部分的程式碼是怎樣的。
為了便於學習與程式碼閱讀,建議你可以在webpack(v4)配置中加入
optimization:{runtimeChunk: {name: 'runtime'}}
,這樣會讓webpack將runtime與模組註冊程式碼分開打包。
// webpack module chunk
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([["home-0"],{
/***/ "module-home-0":
/***/ (function(module, exports, __webpack_require__) {
const myalert = __webpack_require__("module-home-1");
myalert('test');
/***/ }),
/***/ "module-home-1":
/***/ (function(module, exports) {
module.exports = function (a) {
alert('hi:' + a);
};
/***/ })
},[["module-home-0","home-1"]]]);
複製程式碼
上面這是一個不包含runtime的chunk,我們不妨將其稱為module chunk(下面會沿用這個叫法)。簡化一下這部分程式碼,大致結構如下:
// webpack module chunk
window["webpackJsonp"].push([
["home-0"], // chunkIds
{
"module-home-0": (function(module, exports, __webpack_require__){ /* some logic */ }),
"module-home-1": (function(module, exports, __webpack_require__){ /* some logic */ })
},
[["module-home-0","home-1"]]
])
複製程式碼
這裡,.push()
方法引數為一個陣列,包含三個元素:
- 第一個元素是一個陣列,
["home-0"]
表示該js檔案所包含的所有chunk的id(可以粗略理解為,webpack中module組成chunk,chunk又組成file); - 第二個元素是一個物件,鍵是各個模組的id,值則是一個被function包裝後的模組;
- 第三個元素也是一個陣列,其又是由多個陣列組成。具體作用我們先按下不表,最後再說。
來看下引數陣列的第二個元素 —— 包含模組程式碼的物件,你會發現這裡方法簽名是不是很像Node中的通過Module.wrap()
進行的模組程式碼包裝?沒錯,webpack原始碼中也有類似,會像Node那樣,將每個模組的程式碼用一個function包裝起來。
而當webpack配置了runtime分離後,打包出的檔案中會出現一個“純淨”的、不包含任何模組程式碼的runtime,其主要是一個自執行方法,其中暴露了一個全域性變數webpackJsonp
:
// webpack runtime chunk
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
jsonpArray.push = webpackJsonpCallback;
複製程式碼
webpackJsonp
變數名可以通過output.jsonpFunction
進行配置
可以看到,window["webpackJsonp"]
上的.push()
方法已經被修改為了webpackJsonpCallback()
方法。該方法如下:
// webpack runtime chunk
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var executeModules = data[2];
var moduleId, chunkId, i = 0, resolves = [];
// webpack會在installChunks中儲存chunk的載入狀態,據此判斷chunk是否載入完畢
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
// 注意,這裡會進行“註冊”,將模組暫存入記憶體中
// 將module chunk中第二個陣列元素包含的 module 方法註冊到 modules 物件裡
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
if(parentJsonpFunction) parentJsonpFunction(data);
while(resolves.length) {
resolves.shift()();
}
deferredModules.push.apply(deferredModules, executeModules || []);
return checkDeferredModules();
};
複製程式碼
注意以上方法的這幾行,就是我們之前所說的「將模組“暫存”在modules物件上」
// webpackJsonpCallback
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
複製程式碼
配合__webpack_require__()
中下面這一行程式碼,就實現了在需要引入模組時,同步地將模組從暫存區取出來執行,避免使用網路請求導致過長的同步等待時間。
// __webpack_require__
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
複製程式碼
3.4. 模組的自動執行
到目前為止,對於webpack的同步依賴實現已經介紹的差不多了,但還遺留一個小問題:webpack中的所有js原始檔都是模組,但如果都是不會自動執行的模組,那我們只是在前端引入了一堆“死”程式碼,怎麼讓程式碼“活”起來呢?
很多時候,我們引入一個script標籤載入指令碼檔案,至少希望其中一個模組的程式碼會自動執行,而不僅僅是註冊在modules
物件上。一般來說,這就是webpack中所謂的入口模組。
webpack是如何讓這些入口模組自動執行的呢?不知道你是否還記得module chunk中那個按下不表的第三個引數:這個引數是一個陣列,而陣列裡面每個元素又是一個陣列
[["module-home-0","home-1"], ["module-home-2","home-3","home-5"]]
複製程式碼
對照上面這個例子,我們可以具體解釋下引數的含義。第一個元素["module-home-0","home-1"]
表示,我希望自動執行moduleId為module-home-0
的這個模組,但是該模組需要chunkId為home-1
的chunk已經載入後才能執行;同理,["module-home-2","home-3","home-5"]
表示自動執行module-home-2
模組,但是需要檢查chunkhome-3
和home-5
已經載入。
執行某些模組需要保證一些chunk已經載入是因為,該模組所依賴的其他模組可能並不在當前chunk中,而webpack在編譯期會通過依賴分析自動將依賴模組的所屬chunkId注入到此處。
這個模組“自動”執行的功能在runtime chunk的程式碼中主要是由checkDeferredModules()
方法實現:
function checkDeferredModules() {
var result;
for(var i = 0; i < deferredModules.length; i++) {
var deferredModule = deferredModules[i];
var fulfilled = true;
// 第一個元素是模組id,後面是其所需的chunk
for(var j = 1; j < deferredModule.length; j++) {
var depId = deferredModule[j];
// 這裡會首先判斷模組所需chunk是否已經載入完畢
if(installedChunks[depId] !== 0) fulfilled = false;
}
// 只有模組所需的chunk都載入完畢,該模組才會被執行(__webpack_require__)
if(fulfilled) {
deferredModules.splice(i--, 1);
result = __webpack_require__(__webpack_require__.s = deferredModule[0]);
}
}
return result;
}
複製程式碼
4. 非同步依賴
如果你只是想學習webpack前端runtime中同步依賴的設計與實現,那麼到這裡主要內容基本已經結束了。不過我們知道,webpack支援使用動態模組引入的語法(程式碼拆分),例如:dynamic import
和早期的require.ensure
,這種方式與使用CommonJS的require
和ESM的import
最重要的區別在於,該類方法會非同步(或者說按需)載入依賴。
4.1. 程式碼轉換
就像在原始碼中使用require
會在webpack打包時被替換為__webpack_require__
一樣,在原始碼中使用的非同步依賴語法也會被webpack修改。以dynamic import
為例,下面的程式碼
import('./test.js').then(mod => {
console.log(mod);
});
複製程式碼
在產出後會被轉換為
__webpack_require__.e(/* import() */ "home-1")
.then(__webpack_require__.bind(null, "module-home-3"))
.then(mod => {
console.log(mod);
});
複製程式碼
上面程式碼是什麼意思呢?我們知道,webpack打包後會將一些module合併為一個chunk,因此上面的"home-1"
就表示:包含./test.js
模組的chunk的chunkId為"home-1"
。
webpack首先通過__webpack_require__.e
載入指定chunk的script檔案(module chunk),該方法返回一個promise,當script載入並執行完成後resolve該promise。webpack打包時會保證非同步依賴的所有模組都已包含在該module chunk或當前上下文中。
既然module chunk已經執行,那麼表明非同步依賴已經就緒,於是在then方法中執行__webpack_require__
引用test.js
模組(webpack編譯後moduleId為module-home-3)並返回。這樣在第二個then方法中就可以正常使用該模組了。
4.2. __webpack_require__.e
非同步依賴的核心方法就是__webpack_require__.e
。下面來分析一下該方法:
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
var installedChunkData = installedChunks[chunkId];
// 判斷該chunk是否已經被載入,0表示已載入。installChunk中的狀態:
// undefined:chunk未進行載入,
// null:chunk preloaded/prefetched
// Promise:chunk正在載入中
// 0:chunk載入完畢
if(installedChunkData !== 0) {
// chunk不為null和undefined,則為Promise,表示載入中,繼續等待
if(installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// 注意這裡installChunk的資料格式
// 從左到右三個元素分別為resolve、reject、promise
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// 下面程式碼主要是根據chunkId載入對應的script指令碼
var 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);
}
// jsonpScriptSrc方法會根據傳入的chunkId返回對應的檔案路徑
script.src = jsonpScriptSrc(chunkId);
onScriptComplete = function (event) {
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);
};
複製程式碼
該方法首先會根據chunkId在installChunks中判斷該chunk是否正在載入或已經被載入;如果沒有則會建立一個promise,將其儲存在installChunks中,並通過jsonpScriptSrc()
方法獲取檔案路徑,通過sciript標籤載入,最後返回該promise。
jsonpScriptSrc()
則可以理解為一個包含chunk map的方法,例如這個例子中:
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"home-1":"0b49ae3b"}[chunkId] + ".js"
}
複製程式碼
其中包含一個map —— {"home-1":"0b49ae3b"}
,會根據home-1這個chunkId返回home-1.0b49ae3b.js這個檔名。
4.3. 更新chunk載入狀態
最後,你會發現,在onload中,並沒有呼叫promise的resolve方法。那麼是何時resolve的呢?
你還記得在介紹同步require時用於註冊module的webpackJsonpCallback()
方法麼?我們之前說過,該方法引數陣列中的第一個元素是一個chunkId的陣列,代表了該指令碼所包含的chunk。
p.s. 當一個普通的指令碼被瀏覽器下載完畢後,會先執行該指令碼,然後觸發onload事件。
因此,在webpackJsonpCallback()
方法中,有一段程式碼就是根據chunkIds的陣列,檢查並更新chunk的載入狀態:
// webpackJsonpCallback()
var moduleId, chunkId, i = 0, resolves = [];
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
// ……
while(resolves.length) {
resolves.shift()();
}
複製程式碼
上面的程式碼先根據模組註冊時的chunkId,取出installedChunks對應的所有loading中的chunk,最後將這些chunk的promise進行resolve操作。
5. 寫在最後
至此,對於「webpack打包後是如何實現前端模組化」這個問題就差不多結束了。本文通過Node中的模組化為引子,介紹了webpack中的同步與非同步模組載入的設計與實現。
為了方便大家對照文中內容檢視webpack執行時原始碼,我把基礎的webpack runtime chunk和module chunk放在了這裡,有興趣的朋友可以對照著看。
最後還是歡迎對webpack感興趣的朋友能夠相互交流,關注我的系列文章。