你真的瞭解前端模塊化麼?
webpack是一個強大而複雜的前端自動化工具。其中一個特色就是配置複雜,這也使得「webpack配置工程師」這種戲謔的稱呼開始流行🤷可是,難道你真的只知足於玩轉webpack配置麼?javascript
顯然不是。在學習如何使用webpack以外,咱們更須要深刻webpack內部,探索各部分的設計與實現。萬變不離其宗,即便有一天webpack「過氣」了,但它的某些設計與實現卻仍會有學習價值與借鑑意義。所以,在學習webpack過程當中,我會總結一系列【webpack進階】的文章和你們分享。前端
歡迎感興趣的同窗多多交流與關注!java
下面進入正題。一直以來,在前端領域,開發人員日益增加的語言能力需求和落後的JavaScript規範造成了一大矛盾。例如,咱們會用babel來進行ES6到ES5的語法轉換,會使用各類polyfill來兼容老式上的新特性……而咱們本文的主角 —— 模塊化也是如此。node
因爲JavaScript在設計之初就沒有考慮這一點,加之模塊化規範的遲到,致使社區中涌現出一系列前端運行時的模塊化方案,例如RequireJS、seaJS等。以及與之對應的編譯期模塊依賴解決方案,例如browserify、rollup和本文的主角webpack。webpack
可是咱們要知道,<script type="module">
還存在必定的兼容性與使用問題。git
在更通用的範圍內來說,瀏覽器原生實際是不支持所謂的CommonJS或ESM模塊化規範的。那麼webpack是如何在打包出的代碼中實現模塊化的呢?github
在探究webpack打包後代碼的模塊化實現前,咱們先來看一下Node中的模塊化。web
NodeJS(如下簡稱爲Node)在模塊化上基本是遵循的CommonJS規範,而webpack打包出來的代碼所實現模塊化的方式,也相似於CommonJS。所以,咱們先以熟悉的Node(這裏主要參考Node v10)做爲引子,簡單介紹它的模塊化實現,幫助咱們接下來理解webpack的實現。json
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感興趣的朋友可以相互交流,關注個人系列文章。