在現代主流的前端項目開發中,幾乎總能找到 webpack 的影子,它彷佛已經成了現今前端開發中不可或缺的一部分。css
下圖是 webpack 官網首頁,它生動形象的展示了 webpack 的核心功能:將一堆依賴關係複雜的模塊打包成整齊有序的靜態資源。前端
webpack 的出現加上現成腳手架的支持,讓咱們能夠集中精力在項目開發上,而無需過多關注打包過程和結果。node
但是,你是否好奇過,webpack 打包後的 js 代碼是如何作到有序加載執行的?webpack
回憶一下,咱們在使用 webpack 的項目中,任何資源均可以被視做模塊(只要有對應的 loader 支持解析),而這時模塊(module)的載體是文件。但在項目打包後,模塊的載體變成了函數,也被稱爲模塊函數(module function),而文件則成了 chunk(塊)的載體。所謂 chunk,是 webpack 中的一個概念,是由若干個模塊組成的一個集合,一個 chunk 每每對應着一個文件。web
實際上,要回答上面提出的問題,就要搞清楚打包後模塊與模塊,chunk 與 chunk,以及模塊與 chunk 之間的關係。因此,下面咱們將從 模塊 和 chunk 這兩個維度展開這篇文章。npm
因爲模塊是資源加載中的最小單位,因此咱們從最簡單的模塊加載開始。json
下面是一個基礎的 webpack4 配置文件。bootstrap
// webpack.config.js
const path = require('path')
module.exports = {
mode: 'production',
entry: {
main: './src/main.js'
},
output: {
filename: '[name].js',
chunkFilename: '[name].[contenthash:8].js',
path: path.resolve(__dirname, 'dist')
},
optimization: {
// 爲了方便閱讀理解打包後的代碼,關閉代碼壓縮和模塊合併
minimize: false,
concatenateModules: false
}
}
複製代碼
在執行構建命令 npm run build
後,項目中會生成一個 dist 文件夾,裏面包含了一個 main.js 文件,下面是文件中的內容。(本文全部的示例代碼爲了聚焦重點,忽略掉了部分暫時無關的代碼,避免加劇讀者閱讀負擔)數組
(function(modules) { // webpackBootstrap
// 用於緩存已加載模塊的地方
var installedModules = {};
// 用於加載模塊的 require 函數
function __webpack_require__(moduleId) { ... }
// 加載入口模塊(假設入口模塊 id 爲0)
return __webpack_require__(0)
})([...])
複製代碼
上面代碼的實質是一個當即調用函數表達式(IIFE),其中的函數部分叫作 webpackBootstrap,傳入的實參是一個包含模塊函數的數組或對象。promise
下面是 bootstrap 的英文含義:
A technique of loading a program into a computer by means of a few initial instructions which enable the introduction of the rest of the program from an input device.
翻譯過來就是一種經過一些初始指令將程序加載到計算機中的技術,該初始指令使得可以從輸入設備引入程序的其他部分。
若是仍是不太理解的話,能夠簡單的將其理解成控制中心,負責一切事物的啓動、調度、執行等。
在 webpackBootstrap 中,定義了一個模塊緩存對象(installedModules)用於存放已加載模塊,以及一個模塊加載函數(__webpack_require__
)用於獲取對應 id 的模塊,最後加載入口模塊以啓動整個程序。
下面咱們重點來說一下模塊的加載。
// 加載模塊
function __webpack_require__(moduleId) {
// 檢查緩存中是否有該模塊,如有,則直接返回
if (installedModules[moduleId]) {
return installedModules[moduleId].exports
}
// 初始化一個新模塊,而且保存到緩存中
var module = installedModules[moduleId] = {
i: moduleId, // 模塊名
l: false, // 布爾值,表示該模塊是否加載完畢
exports: {} // 模塊的輸出對象,包含了模塊輸出的各個接口
}
// 執行模塊函數,並傳入三個實參:模塊自己、模塊的輸出對象、加載函數,同時定義 this 值爲模塊的輸出對象
modules[moduleId].call(
module.exports,
module,
module.exports,
__webpack_require__
)
// 標記模塊爲已加載狀態
module.l = true
// 返回模塊的輸出對象
return module.exports
}
複製代碼
上面的代碼說明,編譯後模塊的加載遵循 CommonJS 規範。不過,CommonJS 模塊規範不是同步加載模塊,不適用於瀏覽器端嗎?實際上,這是由於 webpack 編譯後的代碼確保了在對模塊進行加載時,模塊已經從服務器下載好了,所以並無同步請求致使的阻塞問題。至於這個問題具體是如何解決的,會在下面的 chunk 章節做出解釋。
下面是模塊加載的流程圖。
不過,這裏有幾點值得注意:
module.exports
,所以利用 call 函數定義模塊函數的 this 值爲 module.exports
。可是在 ES6 模塊中,頂層的 this 爲 undefined,因此在編譯時 this 就被轉換成了 undefined。那麼執行模塊函數時究竟作了什麼呢?簡單來講,就是將被加載模塊的輸出接口添加到輸出對象上。
下面咱們經過一個簡單的例子來看看模塊函數(因爲 webpack 官方推薦使用 ES6 模塊語法,所以示例中使用 ES6 中的 import/export)。
// src/lib.js
export let counter = 0
export function plusOne() {
counter++
}
// src/main.js(入口模塊)
import { counter, plusOne } from './lib'
console.log(counter)
plusOne()
console.log(counter)
複製代碼
下面是 webpack 打包編譯後的模塊函數。
(function(modules) {
...
// 步驟1:加載入口模塊
return __webpack_require__(1)
})(
[
/* moduleId: 0 */
function(module, __webpack_exports__, __webpack_require__) {
// ES6模塊默認採用嚴格模式
'use strict'
// 步驟1.1.1:在輸出對象上定義輸出的各個接口
__webpack_require__.d(__webpack_exports__, 'a', function() {
return counter
})
__webpack_require__.d(__webpack_exports__, 'b', function() {
return plusOne
})
// 步驟1.1.2:聲明定義輸出接口的值
let counter = 0
function plusOne() {
counter++
}
},
/* moduleId: 1 */
function(module, __webpack_exports__, __webpack_require__) {
'use strict'
// 步驟1.1:加載lib.js模塊,並返回其輸出對象
var _lib__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(0)
// _lib__WEBPACK_IMPORTED_MODULE_0__ = {
// get a() { return couter },
// get b() { return pluseOne }
// }
// 步驟1.2:調用輸出對象上的輸出接口
console.log(_lib__WEBPACK_IMPORTED_MODULE_0__[/* counter */ 'a'])
Object(_lib__WEBPACK_IMPORTED_MODULE_0__[/* plusOne */ 'b'])()
console.log(_lib__WEBPACK_IMPORTED_MODULE_0__[/* counter */ 'a'])
}
]
)
複製代碼
上面代碼中,ES6 模塊文件被編譯成了 CommonJS 規範的模塊函數。爲了保持 ES6 模塊語法的特性,編譯後的代碼變得有些晦澀難懂。其中有幾點比較費解:
__webpack_require__.d
函數是用來幹什麼的?
這個函數是爲了在輸出對象上定義輸出的各個接口。但是簡單的對象屬性賦值不就能夠完成這個任務嗎?這是由於ES6 模塊輸出的是值的只讀引用。
下面是 __webpack_require__.d
的實現。
__webpack_require__.d = function(exports, name, getter) {
// __webpack_require__.o 是用於判斷輸出對象上是否已存在同名的輸出接口
if (!__webpack_require__.o(exports, name)) {
Object.defineProperty(exports, name, { enumerable: true, get: getter })
}
}
複製代碼
上面的代碼代表在輸出 ES6 模塊的接口時,會使用 Object.defineProperty
方法來定義輸出對象上的屬性,並且只定義屬性的 getter(取值器函數),以此實現了輸出接口爲只讀。再經過屬性的 getter 配合閉包實現了輸出接口爲值的引用。
爲何統一在模塊函數頂部定義輸出接口(除 export default
的一些特殊場景之外,如 export default 1
這樣沒有明確指定輸出接口名的)?
這是由於 ES6 模塊是編譯時輸出接口,相比之下,CommonJS 模塊是運行時加載。這二者間的區別在模塊間循環加載問題中會獲得體現。所以爲了模擬 ES6 模塊的這個特性,須要在模塊加載所依賴的模塊或執行其餘操做前,先定義輸出接口名。
爲何在消費 lib 模塊的輸出接口時,須要每次都從輸出對象上取(如步驟1.2中消費 couter 值),而不像原代碼中輸出接口是獨立的(如原代碼中的 couter 變量)?
這是因爲輸出對象上的屬性其實是一個 getter 函數,若是將該屬性值取出單獨聲明一個變量,便失去閉包的效果,沒法追蹤被加載模塊中輸出接口值的變更,也就失去了輸出接口爲值的引用這一 ES6 模塊的特性。以上面示例代碼舉例,正常狀況下控制檯依次輸出0和1,但若是將編譯後的輸出接口值賦予一個新變量,控制檯則會輸出兩次0。
若是一個項目的代碼體積變大,那麼將全部 js 代碼打包到一個文件中必然會遇到性能瓶頸,致使資源加載時間過長。這時候,webpack 的 split chunks 技術就派上了用場。能夠根據不一樣的分包優化策略,將模塊拆分到不一樣的 chunk 文件中。
對於一些訪問頻率較低的路由或使用頻率較低的組件能夠經過懶加載拆分爲異步 chunk。
異步 chunk 能夠經過調用 import()
方法動態加載模塊獲得。下面咱們改造一下 main.js 文件,以懶加載 lib 模塊。
// src/main.js
import('./lib').then((lib) => {
console.log(lib.counter)
lib.plusOne()
console.log(lib.counter)
})
複製代碼
下面是從新構建打包後的 main.js 文件(只展現了新增的和發生變動的代碼)。
// dist/main.js
(function(modules) {
// chunk 下載完畢後執行的函數
function webpackJsonpCallback(data) { ... }
// 用於標記各個 chunk 加載狀態的對象
// undefined:chunk 未加載
// null:chunk preloaded/prefetched
// Promise:chunk 正在加載
// 0:chunk 已加載
var installedChunks = {
0: 0
}
// 獲取 chunk 的請求地址(url),包含了 chunk 名及 chunk 哈希
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + ({}[chunkId]||chunkId) + "." + {"1":"3215c03a"}[chunkId] + ".js"
}
// 獲取 chunk
__webpack_require__.e = function requireEnsure(chunkId) { ... }
// chunk 的公共路徑(public path),即 webpack 配置中的 output.publicPath
__webpack_require__.p = "";
// 圍繞 webpackJsonp 的一系列操做,會在下面作詳細介紹
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__(0);
})([
/* 0 */
(function(module, exports, __webpack_require__) {
__webpack_require__.e(/* import() */ 1).then(__webpack_require__.bind(null, 1)).then((lib) => {
console.log(lib.counter)
lib.plusOne()
console.log(lib.counter)
})
})
])
複製代碼
__webpack_require__.e
上面的代碼中,入口模塊中的 import('./lib')
被編譯爲了 __webpack_require__.e(1).then(__webpack_require__.bind(null, 1))
, 這實際上等價於下面的代碼。
__webpack_require__.e(1)
.then(function() {
return __webpack_require__(1)
})
複製代碼
上面的代碼由兩部分組成,前半段的 __webpack_require__.e(1)
是用來異步加載 chunk 的,後半段傳入 then 方法中的回調函數是用來同步加載 lib 模塊的。
這就解決了 CommonJS 規範中同步加載模塊會在瀏覽器端致使的執行堵塞問題。
// 獲取 chunk
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
// 利用 JSONP 下載 js chunk
var installedChunkData = installedChunks[chunkId];
// 0表明該 chunk 已加載完畢
if(installedChunkData !== 0) {
if(installedChunkData) { // chunk 正在加載中
promises.push(installedChunkData[2]);
} else {
// 在 chunk 緩存中更新 chunk 狀態爲正在加載中,並緩存 resolve、reject、promise
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// 開始準備下載 chunk
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);
// 在堆棧展開以前建立 error 以便稍後得到有用的堆棧跟蹤
var error = new Error();
// chunk 下載完成後(成功或異常)的回調函數
onScriptComplete = function (event) {
// 防止 IE 瀏覽器下內存泄漏
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;
error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')';
error.name = 'ChunkLoadError';
error.type = errorType;
error.request = realSrc;
// reject(error)
chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
// 處理請求超時
var timeout = setTimeout(function(){
onScriptComplete({ type: 'timeout', target: script });
}, 120000);
// 處理請求成功及異常
script.onerror = script.onload = onScriptComplete;
// 發起請求
document.head.appendChild(script);
}
}
return Promise.all(promises);
};
複製代碼
下面是__webpack_require__.e
的執行流程:
[resolve, reject, promise]
)保存到 chunk 緩存中,再將 promise 實例添加到 promises 數組中。Promise.all(promises)
,等全部異步 chunks 都加載成功後,再觸發 then 方法中的回調函數(即加載 chunk 中包含的模塊)。或許有人會感到困惑,爲何沒有在 onScriptComplete 中 chunk === 0
時執行 resolve 函數?就像下面這樣:
onScriptComplete = function (event) {
...
var chunk = installedChunks[chunkId];
if(chunk !== 0) {
if(chunk) {
...
// reject(error)
chunk[1](error);
}
installedChunks[chunkId] = undefined;
} else { // chunk === 0
// resolve()
chunk[0]()
}
};
複製代碼
這個問題的實質是 chunk 的異步加載流程何時纔算結束?是否 chunk 下載完成後就算結束了?實際上,js chunk 的加載包含兩個部分:下載 chunk 文件和執行 chunk 代碼,只有當二者都完成後,該 chunk 纔算加載完成。所以,resolve 被保存到了 chunk 緩存中,待 chunk 代碼執行完畢後再執行 resolve 函數,結束掉異步加載流程。雖然 script 的 load 事件是在其下載並執行完畢後才觸發,可是 load 事件只關心下載自己,即便 script 在執行過程當中拋出異常,依然會觸發 load 事件。
當 js chunk 下載成功後,就會開始執行代碼,下面是 lib.js 模塊打包獲得的 chunk。
// dist/1.3215c03a.js
(window["webpackJsonp"] = window["webpackJsonp"] || []).push([[1],[
/* 0 */,
/* 1 */
(function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_require__.d(__webpack_exports__, "counter", function() { return counter; });
__webpack_require__.d(__webpack_exports__, "plusOne", function() { return plusOne; });
let counter = 0
function plusOne() {
counter++
}
})
]]);
複製代碼
上面的代碼看上去很簡單,只有兩步操做,一個是初始化 window["webpackJsonp"]
爲數組(若以前未被初始化), 另外一個是經過 push 操做將一個數組添加到 window["webpackJsonp"]
數組中(表達並不嚴謹,詳情見下文)。其中做爲實參的數組又由兩個數組構成,第一個數組是 chunkId 的集合(正常狀況下,該數組只包含當前 chunkId。但在分包策略有誤的狀況下,該數組可能包含多個 chunkId),第二個數組則是模塊函數的集合。
可是,原生的 push 操做只能簡單的將 chunk 中的數據添加到數組裏。那 webpack 到底是在哪裏對數據作的處理?又是如何作的處理?
若是還有印象的話,在上面的 webpackBootstrap 中有一段圍繞 window["webpackJsonp"]
數組的操做。
var jsonpArray = window["webpackJsonp"] = window["webpackJsonp"] || [];
var oldJsonpFunction = jsonpArray.push.bind(jsonpArray);
// 將 window["webpackJsonp"].push 方法替換爲 webpackJsonpCallback 函數
jsonpArray.push = webpackJsonpCallback;
jsonpArray = jsonpArray.slice();
// 對以前已加載的所有初始 chunk 中的數據調用 webpackJsonpCallback
for(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);
// 將 window["webpackJsonp"] 數組原生的 push 方法賦給 parentJsonpFunction 變量
var parentJsonpFunction = oldJsonpFunction;
複製代碼
從上面代碼能夠看出,全部的 chunks 中的數據(除 webpackBootstrap 所在的 chunk 之外)都是經過 JSONP 的形式(即調用 webpackJsonpCallback)加載進來並作處理的,但在 webpackBootstrap 所在的 chunk 未加載好的以前,webpackJsonpCallback 還未被聲明定義,所以便將數據都先暫時保存在 window["webpackJsonp"]
數組裏,待其加載好以後,先將 window["webpackJsonp"]
數組的 push 方法替換爲 webpackJsonpCallback 函數(這樣一來,以後加載的 chunk 雖然調用的是 push 方法,但其實是直接調用 webpackJsonpCallback 函數處理數據),再將先前保存在 window["webpackJsonp"]
數組裏的數據依次調用 webpackJsonpCallback。
function webpackJsonpCallback(data) {
var chunkIds = data[0];
var moreModules = data[1];
var moduleId, chunkId, i = 0, resolves = [];
// 取出各個異步 chunk 所對應的 promise 的 resolve 函數,並在 chunk 緩存中標記 chunk 狀態爲已加載
for(;i < chunkIds.length; i++) {
chunkId = chunkIds[i];
if(installedChunks[chunkId]) {
resolves.push(installedChunks[chunkId][0]);
}
installedChunks[chunkId] = 0;
}
// 將 chunk 中包含的模塊都添加到 webpackBootstrap 的 modules 對象中
for(moduleId in moreModules) {
if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
modules[moduleId] = moreModules[moduleId];
}
}
// 利用 window["webpackJsonp"] 數組原生的 push 方法將 chunk 中的數據添加到 window["webpackJsonp"] 中
if(parentJsonpFunction) parentJsonpFunction(data);
// 異步 chunks 加載成功,執行 resolve 函數來 fulfill 各個 chunk 對應的 promise,觸發 then 中的回調函數
while(resolves.length) {
resolves.shift()();
}
};
複製代碼
webpackJsonpCallback 函數主要對 chunk 中的數據作了兩個處理:緩存、結束異步加載流程。
緩存包含兩個層面:一個是對 chunk 加載狀態的緩存,以免對同一 chunk 發送屢次請求,另外一個是對模塊函數的緩存,以便後期對模塊的加載。
結束 chunk 的異步加載流程,實際就是執行 chunk 緩存中的 resolve 函數。
對於網站初始階段須要加載的模塊,能夠根據模塊的體積大小、共用率、更新頻率,拆分爲核心基礎類庫、UI 組件庫、業務代碼等多個初始 chunk。
爲了獲得多個初始 chunk,調整一下 main.js 文件和 webpack.config.js 配置。
// src/main.js
import * as _ from 'lodash'
const arr = [1, 2]
console.log(_.concat(arr, 3, [4]))
// webpack.config.js(基於上面的 webpack 配置)
module.exports = {
...
optimization: {
...
splitChunks: {
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: 10,
},
}
}
}
}
複製代碼
上面代碼中,main.js 模塊依賴了 lodash 庫,並將 lodash 庫拆分到一個單獨的 chunk 中,因此在 webpack 配置的 optimization 對象中增長了 splitChunks 配置,用於將 lodash 庫拆分到名爲 vendors 的 chunk 中。
下面是打包後 webpackBootstrap 中增長的代碼。
(function(modules) { // webpackBootstrap
function webpackJsonpCallback(data) {
...
var executeModules = data[2];
// 若是加載的 chunk 中有入口模塊,則將其添加到 deferredModules 數組
deferredModules.push.apply(deferredModules, executeModules || []);
return checkDeferredModules();
}
// 檢查入口模塊所依賴的 chunk 是否加載完成,若是是,則加載入口模塊,不然不執行任何操做
function checkDeferredModules() {
var result;
// 遍歷全部入口模塊
for(var i = 0; i < deferredModules.length; i++) {
var deferredModule = deferredModules[i];
var fulfilled = true;
// 檢查入口模塊所依賴的所有 chunk 是否加載完成
for(var j = 1; j < deferredModule.length; j++) {
var depId = deferredModule[j];
if(installedChunks[depId] !== 0) fulfilled = false;
}
// 若是入口模塊依賴的所有 chunk 都加載完成,則加載入口模塊
if(fulfilled) {
deferredModules.splice(i--, 1);
result = __webpack_require__(deferredModule[0]);
}
}
return result;
}
var deferredModules = [];
// 將入口模塊添加到 deferredModules 數組
// 數組中第一個元素爲入口模塊 id,後面的元素都是入口模塊依賴的初始 chunk 的 id
deferredModules.push([1,1]);
return checkDeferredModules();
})(...)
複製代碼
原來只有一個初始 chunk 時, chunk 中包含了初始階段所需的所有模塊,因此當其下載好後就能夠直接加載入口模塊。但當模塊被拆分到多個初始 chunk 中的時候,必須得等所有初始 chunk 都加載完成,所有初始階段所需的模塊都準備好後,才能夠開始加載入口模塊。所以,惟一不一樣的是入口模塊的加載時機被 defer(延遲) 了。
因此在上面代碼中,webpackBootstrap 函數和 webpackJsonpCallback 函數都在最後調用了 checkDeferredModules 函數,確保全部 chunk 在加載完成後都會檢查是否有入口模塊已經知足了要求(即其依賴的所有初始 chunk 都已加載完成),若是有入口模塊知足了,則開始加載該入口模塊。
本文實際上就解答了一個問題:webpack 打包後的 js 代碼是如何運行的?答案的核心有兩點:模塊的加載和 chunk 的加載。前者同步阻塞,後者異步非阻塞。當你清楚如何將二者和諧的配合在一塊兒時,也就離完整的答案不遠了。