最近在作一個工程化強相關的項目-微前端,涉及到了基座項目和子項目加載,並存的問題;之前對webpack一直停留在配置,也就是常說的入門級。此次項目推進,本身不得不邁過門檻,往裏面多看一點。javascript
本文主要講webpack構建後的文件,是怎麼在瀏覽器運行起來的,這可讓咱們更清楚明白webpack的構建原理。
文章中的代碼基本只含核心部分,若是想看所有代碼和webpack配置,能夠關注工程,本身拷貝下來運行: demo地址html
在讀本文前,須要知道webpack的基礎概念,知道chunk 和 module的區別前端
本文將按部就班,來解析webpack打包後的代碼是怎麼在瀏覽器跑起來的。將從如下三個步驟揭開黑盒:java
原文連接:webpack 打包的代碼怎麼在瀏覽器跑起來的?看不懂算我輸node
最簡單的打包場景是什麼呢,就是打包出來html文件只引用一個js文件,項目就能夠跑起來,舉個🌰:webpack
// 入口文件:index.js import sayHello from './utils/hello'; import { util } from './utils/util'; console.log('hello word:', sayHello()); console.log('hello util:', util); // 關聯模塊:utils/util.js export const util = 'hello utils'; // 關聯模塊:utils/hello.js import { util } from './util'; console.log('hello util:', util); const hello = 'Hello'; export default function sayHello() { console.log('the output is:'); return hello; };
入門級的代碼,簡單來說就是入口文件依賴了兩個模塊: util 與 hello,而後模塊hello,又依賴了util,最後運行html文件,能夠在控制檯看到console打印。打包後的代碼長什麼樣呢,看下面,刪除了一些干擾代碼,只保留了核心部分,加了註釋,但仍是較長,須要耐心:git
(function(modules) { // webpackBootstrap // 安裝過的模塊的緩存 var installedModules = {}; // 模塊導入方法 function __webpack_require__(moduleId) { // 安裝過的模塊,直接取緩存 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 沒有安裝過的話,那就須要執行模塊加載 var module = installedModules[moduleId] = { i: moduleId, l: false, exports: {} }; // 上面說的加載,其實就是執行模塊,把模塊的導出掛載到exports對象上; modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // 標識模塊已加載過 module.l = true; // Return the exports of the module return module.exports; } // 暴露入口輸入模塊; __webpack_require__.m = modules; // 暴露已經加載過的模塊; __webpack_require__.c = installedModules; // 模塊導出定義方法 // eg: export const hello = 'Hello world'; // 獲得: exprots.hello = 'Hello world'; __webpack_require__.d = function (exports, name, getter) { if (!__webpack_require__.o(exports, name)) { Object.defineProperty(exports, name, { enumerable: true, get: getter }); } }; // __webpack_public_path__ __webpack_require__.p = ''; // 從入口文件開始啓動 return __webpack_require__(__webpack_require__.s = "./src/index.js"); })({ "./webpack/src/index.js": /*! no exports provided */ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); var _utils_hello__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./utils/hello */ "./webpack/src/utils/hello.js"); var _utils_util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./utils/util */ "./webpack/src/utils/util.js"); console.log('hello word:', Object(_utils_hello__WEBPACK_IMPORTED_MODULE_0__["default"])()); console.log('hello util:', _utils_util__WEBPACK_IMPORTED_MODULE_1__["util"]); }), "./webpack/src/utils/hello.js": (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); __webpack_require__.d(__webpack_exports__, "default", function() { return sayHello; }); var _util__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./util */ "./webpack/src/utils/util.js"); console.log('hello util:', _util__WEBPACK_IMPORTED_MODULE_0__["util"]); var hello = 'Hello'; function sayHello() { console.log('the output is:'); return hello; } }), "./webpack/src/utils/util.js": (function(module, __webpack_exports__, __webpack_require__) { "use strict"; __webpack_require__.r(__webpack_exports__); __webpack_require__.d(__webpack_exports__, "util", function() { return util; }); var util = 'hello utils'; }) });
咋眼一看上面的打包結果,其實就是一個IIFE(當即執行函數),這個函數
就是webpack
的啓動代碼,裏面包含了一些變量方法聲明;而輸入
是一個對象,這個對象描述的就是咱們代碼中編寫的文件,文件路徑爲對面key,value就是文件中定義的代碼,但這個代碼是被一個函數包裹的:github
/** * module: 就是當前模塊 * __webpack_exports__: 就是當前模塊的導出,即module.exports * __webpack_require__: webpack加載器對象,提供了依賴加載,模塊定義等能力 **/ function(module, __webpack_exports__, __webpack_require__) { // 文件定義的代碼 }
加載的原理,在上面代碼中已經作過註釋了,耐心點,一分鐘就明白了,仍是加個圖吧,在vscode中用drawio插件畫的,感覺一下: web
除了上面的加載過程,再說一個細節,就是webpack怎麼分辨依賴包是ESM仍是CommonJs模塊,仍是看打包代碼吧,上面輸入模塊在開頭都會執行__webpack_require__.r(__webpack_exports__)
, 省略了這個方法的定義,這裏補充一下,解析看代碼註釋:json
// 定義模塊類型是__esModule, 保證模塊能被其餘模塊正確導入, __webpack_require__.r = function (exports) { if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }); } // 模塊上定義__esModule屬性, __webpack_require__.n方法會用到 // 對於ES6 MOdule,import a from 'a'; 獲取到的是:a[default]; // 對於cmd, import a from 'a';獲取到的是整個module Object.defineProperty(exports, '__esModule', { value: true }); }; // esModule 獲取的是module中的default,而commonJs獲取的是所有module __webpack_require__.n = function (module) { var getter = module && module.__esModule ? function getDefault() { return module['default']; } : function getModuleExports() { return module; }; // 爲何要在這個方法上定義一個 a 屬性? 看打包後的代碼, 好比:在引用三方時 // 使用import m from 'm', 而後調用m.func(); // 打出來的代碼都是,獲取模塊m後,最後執行時是: m.a.func(); __webpack_require__.d(getter, 'a', getter); return getter; };
看完最簡單的,如今來看一個最多見的,引入splitChunks,多chunk構建,執行流程有什麼改變。咱們經常會將一些外部依賴打成一個js包,項目本身的資源打成一個js包;
仍是剛剛的節奏,先看打包前的代碼:
// 入口文件:index.js + import moment from 'moment'; + import cookie from 'js-cookie'; import sayHello from './utils/hello'; import { util } from './utils/util'; console.log('hello word:', sayHello()); console.log('hello util:', util); + console.log('time', moment().format('YYYY-MM-DD')); + cookie.set('page', 'index'); // 關聯模塊:utils/util.js + import moment from 'moment'; export const util = 'hello utils'; export function format() { return moment().format('YYYY-MM-DD'); } // 關聯模塊:utils/hello.js // 沒變,和上面同樣
從上面代碼能夠看出,咱們引入了moment與js-cookie兩個外部JS包,並採用分包機制,將依賴node_modules中的包打成了一個單獨的,下面是多chunk打包後的html文件截圖:
再看看async.js 包長什麼樣:
// 僞代碼,隱藏了 moment 和 js-cookie 的代碼細節 (window["webpackJsonp"] = window["webpackJsonp"] || []).push([["async"],{ "./node_modules/js-cookie/src/js.cookie.js": (function(module, exports, __webpack_require__) {}), "./node_modules/moment/moment.js": (function(module, exports, __webpack_require__) {}) })
咋同樣看,這個代碼甚是簡單,就是一個數組push操做,push的元素是一個數組[["async"],{}]
, 先提早說一下,數組第一個元素數組,是這個文件包含的chunk name
, 第二個元素對象,其實就和第一節簡單文件打包的輸入同樣,是模塊名和包裝後的模塊代碼;
再看一下index.js 的變化:
(function(modules) { // webpackBootstrap // 新增 function webpackJsonpCallback(data) { return checkDeferredModules(); }; function checkDeferredModules() { } // 緩存加載過的模塊 var installedModules = {}; // 存儲 chunk 的加載狀態 // undefined = chunk not loaded, null = chunk preloaded/prefetched // Promise = chunk loading, 0 = chunk loaded var installedChunks = { "index": 0 }; var deferredModules = []; // on error function for async loading __webpack_require__.oe = function(err) { console.error(err); throw err; }; // 加載的關鍵 var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); jsonpArray.push = webpackJsonpCallback; jsonpArray = jsonpArray.slice(); for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); var parentJsonpFunction = oldJsonpFunction; // 從入口文件開始啓動 - return __webpack_require__(__webpack_require__.s = "./src/index.js"); // 將入口加入依賴延遲加載的隊列 + deferredModules.push(["./webpack/src/index.js","async"]); // 檢查可執行的入口 + return checkDeferredModules(); }) ({ // 省略; })
從上面的代碼看,支持多chunk執行,webpack 的bootstrap,仍是作了不少工做的,我這大概列一下:
checkDeferredModules
,用於依賴chunk檢查是否已準備好;webpackJsonp
全局數組,用於文件間的通訊與模塊存儲;通訊是經過攔截push
操做完成的;push的代理
操做,也是整個實現的核心;入口文件
執行方式,依賴deferredModules實現;這裏面文章不少,咱們來一一破解:
// 檢查window["webpackJsonp"]數組是否已聲明,若是未聲明的話,聲明一個; var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || []; // 對webpackJsonp原生的push操做作緩存 var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 使用開頭定義的webpackJsonpCallback做爲代碼,即代碼中執行indow["webpackJsonp"].push時會觸發這個操做 jsonpArray.push = webpackJsonpCallback; // 這不操做,其實就是jsonpArray開始是window["webpackJsonp"]的快捷操做,如今咱們對她的操做已完,就斷開了這個引用,但值仍是要,用於後面遍歷 jsonpArray = jsonpArray.slice(); // 這一步,其實要知道他的場景,才知道他的意義,若是光看代碼,以爲這個數組剛聲明,遍歷有什麼用; // 其實這裏是在依賴的chunk 先加載完的狀況,但攔截代理當時還沒生效;因此手動遍歷一次,讓已加載的模塊再走一次代理操做; for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); // 這個操做就是個賦值語句,意義不大; var parentJsonpFunction = oldJsonpFunction;
直接寫上面註釋了,webpackJsonpCallback在後面會解密。
function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; var executeModules = data[2]; // add "moreModules" to the modules object, var moduleId, chunkId, i = 0, resolves = []; for(;i < chunkIds.length; i++) { chunkId = chunkIds[i]; // 下一節再講 installedChunks[chunkId] = 0; } for(moduleId in moreModules) { if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { // 將其餘chunk中的模塊加入到主chunk中; modules[moduleId] = moreModules[moduleId]; } } // 這裏纔是原始的push操做 if(parentJsonpFunction) parentJsonpFunction(data); while(resolves.length) { // 下一節再講 } // 這一句在這裏沒什麼用 deferredModules.push.apply(deferredModules, executeModules || []); // run deferred modules when all chunks ready return checkDeferredModules(); };
還記得前面push的數據是什麼格式嗎:
window["webpackJsonp"].push([["async"], moreModules])
攔截了push操做後,其實就作了三件事:
function checkDeferredModules() { var result; for(var i = 0; i < deferredModules.length; i++) { var deferredModule = deferredModules[i]; var fulfilled = true; for(var j = 1; j < deferredModule.length; j++) { // depId, 即指依賴的chunk的ID,,對於入口‘./webpack/src/index.js’這個deferredModule,depId就是‘async’,等async模塊加載後就能夠執行了 var depId = deferredModule[j]; if(installedChunks[depId] !== 0) fulfilled = false; } if(fulfilled) { // 執行過了,就把這個延遲執行項移除; deferredModules.splice(i--, 1); // 執行./webpack/src/index.js模塊 result = __webpack_require__(__webpack_require__.s = deferredModule[0]); } } return result; }
還記得入口文件的執行替換成了: deferredModules.push(["./webpack/src/index.js","async"])
, 而後執行checkDeferredModules。
這個函數,就是檢查哪些chunk安裝了,但有些module執行,須要依賴某些
chunk,等依賴的chunk加載了,再執行這個module。上面的那一句代碼就是./webpack/src/index.js
這個模塊執行依賴async這個chunk。
到這裏,彷佛多chunk打包,文件的執行流程就算理清楚了,若是你能想明白在html中下面兩種方式,都不會致使文件執行失敗,你就真的明白了:
<!-- 依賴項在前加載 --> <script type="text/javascript" src="async.bundle_9b9adb70.js"></script> <script type="text/javascript" src="index.4f7fc812.js"></script> <!-- 或依賴項在後加載 --> <script type="text/javascript" src="index.4f7fc812.js"></script> <script type="text/javascript" src="async.bundle_9b9adb70.js"></script>
等多包加載理清後,再看按需加載,就沒有那麼複雜了,由於不少實現是在多包加載的基礎上完成的,爲了讓理論更清晰,我添加了兩處按需加載,仍是那個節奏:
// 入口文件,index.js, 只列出新增代碼 let count = 0; const clickButton = document.createElement('button'); const name = document.createTextNode("CLICK ME"); clickButton.appendChild(name); document.body.appendChild(clickButton); clickButton.addEventListener('click', () => { count++; import('./utils/math').then(modules => { console.log('modules', modules); }); if (count > 2) { import('./utils/fire').then(({ default: fire }) => { fire(); }); } }) // utils/fire export default function fire() { console.log('you are fired'); } // utils/math export default function add(a, b) { return a + b; }
代碼很簡單,就是在頁面添加了一個按鈕,當按鈕被點擊時,按需加載utils/math
模塊,並打印輸出的模塊;當點擊次數大於兩次時,按需加載utils/fire
模塊,並調用其中暴露出的fire函數。相對於上一次,會多打出兩個js 文件:0.bundle_29180b93.js 與 1.bundle_42bc336c.js,這裏就列其中一個的代碼:
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],{ "./webpack/src/utils/math.js": (function(module, __webpack_exports__, __webpack_require__) {}) }]);
格式與上面的async chunk 格式如出一轍。
而後再來看index.js 打包完,新增了哪些:
(function(modules) { // script url 計算方法。下面的兩個hash 是否似曾相識,對,就是兩個按需加載文件的hash值 // 傳入0,返回的就是0.bundle_29180b93.js這個文件名 function jsonpScriptSrc(chunkId) { return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + ".bundle_" + {"0":"29180b93","1":"42bc336c"}[chunkId] + ".js" } // 按需加載script 方法 __webpack_require__.e = function requireEnsure(chunkId) { // 後面詳講 }; })({ "./webpack/src/index.js": (function(module, __webpack_exports__, __webpack_require__) { // 只列出按需加載utils/fire.js的代碼 __webpack_require__.e(/*! import() */ 0) .then(__webpack_require__.bind(null, "./webpack/src/utils/fire.js")) .then(function (_ref) { var fire = _ref["default"]; fire(); }); } })
在上一節的接觸上,只加了不多的代碼,主要涉及到兩個方法jsonpScriptSrc
與 requireEnsure
,前者在註釋裏已經寫得很清楚了,後者其實就是動態建立script標籤,動態加載須要的js文件,並返回一個Promise
,來看一下代碼:
__webpack_require__.e = function requireEnsure(chunkId) { var promises = []; var installedChunkData = installedChunks[chunkId]; // 0 意爲着已加載. if(installedChunkData !== 0) { // a Promise means "currently loading": 意外着,已經在加載中 // 須要把加載那個promise:(即下面new的promise)加入到當前的依賴項中; if(installedChunkData) { promises.push(installedChunkData[2]); } else { // setup Promise in chunk cache:new 一個promise var promise = new Promise(function(resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); // 這裏將promise自己記錄到installedChunkData,就是以防上面多個chunk同時依賴一個script的時候 promises.push(installedChunkData[2] = promise); // 下面都是動態加載script標籤的常規操做 var script = document.createElement('script'); var onScriptComplete; script.charset = 'utf-8'; script.timeout = 120; if (__webpack_require__.nc) { script.setAttribute("nonce", __webpack_require__.nc); } script.src = jsonpScriptSrc(chunkId); // 下面的代碼都是錯誤處理 var error = new Error(); onScriptComplete = function (event) { // 錯誤處理 }; var timeout = setTimeout(function(){ onScriptComplete({ type: 'timeout', target: script }); }, 120000); script.onerror = script.onload = onScriptComplete; // 添加script到body document.head.appendChild(script); } } return Promise.all(promises); };
相對來講requireEnsure的代碼實現並無多麼特別,都是一些常規操做,但沒有用經常使用的onload回調,而改用promise
來處理,仍是比較巧妙的。模塊是否已經加裝好,仍是利用前面的webpackJsonp的push代理來完成。
如今再來補充上面一節說留着下一節講的代碼:
function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; var executeModules = data[2]; var moduleId, chunkId, i = 0, resolves = []; for(;i < chunkIds.length; i++) { chunkId = chunkIds[i]; if(Object.prototype.hasOwnProperty.call(installedChunks, chunkId) && installedChunks[chunkId]) { // installedChunks[chunkId] 在這裏加載時,仍是一個數組,元素分別是[resolve, reject, promise],這裏取的是resolve回調; resolves.push(installedChunks[chunkId][0]); } installedChunks[chunkId] = 0; // moreModules 注入忽略 while(resolves.length) { // 這裏resolve時,那麼promise.all 就完成了 resolves.shift()(); } } }
因此上面的代碼作的,仍是利用了這個代理,在chunk加載完成時,來把剛剛產生的promise resolved
掉,這樣按需加載的then就繼續往下執行了,很是曲折的一個發佈訂閱。
自此,對webpack打包後的代碼執行過程就分析完了,由簡入難,若是多一點耐心,仍是比較容易就看懂的。畢竟wbepack的高深,是隱藏在webpack自身的插件系統中的,打出來的代碼基本是ES5級別的,只是用了一些巧妙的方法,好比push的攔截代理。
若是有什麼不清楚的,推薦clone項目,本身打包分析一下代碼:demo地址: webpack項目