你真的瞭解前端模塊化麼?javascript
webpack是一個強大而複雜的前端自動化工具。其中一個特色就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行🤷可是,難道你真的只知足於玩轉webpack配置麼?前端
顯然不是。在學習如何使用webpack以外,咱們更須要深刻webpack內部,探索各部分的設計與實現。萬變不離其宗,即便有一天webpack「過氣」了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。所以,在學習webpack過程當中,我會總結一系列【webpack進階】的文章和你們分享。java
歡迎感興趣的同窗多多交流與關注!node
下面進入正題。一直以來,在前端領域,開發人員日益增加的語言能力需求和落後的JavaScript規範造成了一大矛盾。例如,咱們會用babel來進行ES6到ES5的語法轉換,會使用各類polyfill來兼容老式上的新特性……而咱們本文的主角 —— 模塊化也是如此。webpack
因爲JavaScript在設計之初就沒有考慮這一點,加之模塊化規範的遲到,致使社區中涌現出一系列前端運行時的模塊化方案,例如RequireJS、seaJS等。以及與之對應的編譯期模塊依賴解決方案,例如browserify、rollup和本文的主角webpack。git
可是咱們要知道,<script type="module">
還存在必定的兼容性與使用問題。github
在更通用的範圍內來說,瀏覽器原生實際是不支持所謂的CommonJS或ESM模塊化規範的。那麼webpack是如何在打包出的代碼中實現模塊化的呢?web
在探究webpack打包後代碼的模塊化實現前,咱們先來看一下Node中的模塊化。json
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
的區別
之因此在介紹「webpack是如何在打包出的代碼中實現模塊化」以前,先用必定篇幅介紹了Node中的模塊化,是由於二者在同步依賴的設計與實現上有殊途同歸之處。理解Node的模塊化對學習webpack頗有幫助。固然,因爲運行環境的不一樣(webpack打包出的代碼運行在客戶端,而Node是在服務端),實現上也有必定的差別。
下面就來看一下,webpack是如何在打包出的代碼中實現前端(客戶端)模塊化的。
和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
。
__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是如何解決這個問題的呢?
咱們仍是回來看下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
對象中取出暫存的模塊並執行。
思路已經清晰了,那麼咱們就來看看,webpack是如何將模塊「暫存」在modules
對象上的。在實際上,webpack打包出來的代碼能夠簡單分爲兩類:
__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);來看下參數數組的第二個元素 —— 包含模塊代碼的對象,你會發現這裏方法簽名是否是很像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__);
複製代碼
到目前爲止,對於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;
}
複製代碼
若是你只是想學習webpack前端runtime中同步依賴的設計與實現,那麼到這裏主要內容基本已經結束了。不過咱們知道,webpack支持使用動態模塊引入的語法(代碼拆分),例如:dynamic import
和早期的require.ensure
,這種方式與使用CommonJS的require
和ESM的import
最重要的區別在於,該類方法會異步(或者說按需)加載依賴。
就像在源碼中使用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方法中就能夠正常使用該模塊了。
__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這個文件名。
最後,你會發現,在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操做。
至此,對於「webpack打包後是如何實現前端模塊化」這個問題就差很少結束了。本文經過Node中的模塊化爲引子,介紹了webpack中的同步與異步模塊加載的設計與實現。
爲了方便你們對照文中內容查看webpack運行時源碼,我把基礎的webpack runtime chunk和module chunk放在了這裏,有興趣的朋友能夠對照着看。
最後仍是歡迎對webpack感興趣的朋友可以相互交流,關注個人系列文章。