在平時開發中我們經常會用到 Webpack這個時下最流行的前端打包工具。它打包開發代碼,輸出能在各種瀏覽器運行的代碼,提升了開發至發布過程的效率。
我們知道一份Webpack配置文件主要包含入口( entry)、輸出文件( output)、模式、加載器( Loader)、插件( Plugin)等幾個部分。但如果只需要組織 JS 文件的話,指定入口和輸出文件路徑即可完成一個迷你項目的打包。下面我們來通過一個簡單的項目來看一下Webpack是怎樣運行的。
同步加載
本文使用 webpack ^4.30.0 作示例.為了更好地觀察產出的文件,我們將模式設置為 development 關閉代碼壓縮,再開啟 source-map 支持原始源代碼調試。除此之外。我們還簡單的寫了一個插件 MyPlugin來去除源碼中的注釋。
新建 src/index.js:
1
|
console.log( 'Hello webpack!' ); |
新建 webpack配置文件 webpack.config.js
1
2
3
4
5
6
7
8
9
10
11
12
13
|
const path = require( 'path' ); const MyPlugin = require( './src/MyPlugin.js' ) module.exports = { mode: 'development' , devtool: 'source-map' , entry: './src/index.js' , output: { path: path.resolve(__dirname, 'dist' ) }, plugins:[ new MyPlugin() ] }; |
新建 src/MyPlugin.js。了解webpack插件更多信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
class MyPlugin { constructor(options) { this .options = options this .externalModules = {} } apply(compiler) { var reg = /( "([^\\\"]*(\\.)?)*" )|( '([^\\\']*(\\.)?)*' )|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g compiler.hooks.emit.tap( 'CodeBeautify' , (compilation)=> { Object.keys(compilation.assets).forEach((data)=> { let content = compilation.assets[data].source() // 欲處理的文本 content = content.replace(reg, function (word) { // 去除注釋后的文本 return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\ //.test(word) ? "" : word; }); compilation.assets[data] = { source(){ return content }, size(){ return content.length } } }) }) } } module.exports = MyPlugin |
現在我們運行命令 webpack --config webpack.config.js ,打包完成后會多出一個輸出目錄 dist:dist/main.js。main 是 webpack 默認設置的輸出文件名,我們快速瞄一眼這個文件:
1
2
3
4
5
6
7
|
( function (modules){ // ... })({ "./src/index.js" : ( function (){ // ... }) }); |
整個文件只含一個立即執行函數(IIFE),我們稱它為 webpackBootstrap,它僅接收一個對象 —— 未加載的 模塊集合(modules),這個modules 對象的 key 是一個路徑,value 是一個函數。你也許會問,這里的模塊是什么?它們又是如何加載的呢?
在細看產出代碼前,我們先豐富一下源代碼:
新文件 src/utils/math.js:
1
2
3
|
export const plus = (a, b) => { return a + b; }; |
修改 src/index.js:
1
2
3
|
import { plus } from './utils/math.js' ; console.log( 'Hello webpack!' ); console.log( '1 + 2: ' , plus(1, 2)); |
我們按照 ES 規范的模塊化語法寫了一個簡單的模塊 src/utils/math.js,給 src/index.js 引用。Webpack 用自己的方式支持了 ES6 Module 規范,前面提到的 module 就是和 ES6 module 對應的概念。
接下來我們看一下這些模塊是如何通 ES5 代碼實現的。再次運行命令 webpack --config webpack.config.js 后查看輸出文件:
1
2
3
4
5
6
7
8
9
10
|
( function (modules){ // ... })({ "./src/index.js" : ( function (){ // ... }), "./src/utils/math.js" : ( function () { // ... }) }); |
IIFE 傳入的 modules 對象里多了一個鍵值對,對應著新模塊 src/utils/math.js,這和我們在源代碼中拆分的模塊互相呼應。然而,有了 modules 只是第一步,這份文件最終達到的效果應該是讓各個模塊按開發者編排的順序運行。
探究 webpackBootstrap
接下來看看 webpackBootstrap 函數中有些什么:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
// webpackBootstrap ( function (modules){ // 緩存 __webpack_require__ 函數加載過的模塊 var installedModules = {}; /** * Webpack 加載函數,用來加載 webpack 定義的模塊 * @param {String} moduleId 模塊 ID,一般為模塊的源碼路徑,如 "./src/index.js" * @returns {Object} exports 導出對象 */ function __webpack_require__(moduleId) { // ... } // 在 __webpack_require__ 函數對象上掛載一些變量及函數 ... // 傳入表達式的值為 "./src/index.js" return __webpack_require__(__webpack_require__.s = "./src/index.js" ); })( /* modules */ ); |
可以看到其實主要做了兩件事:
定義一個模塊加載函數__webpack_require__。
使用加載函數加載入口模塊 "./src/index.js"。
整個 webpackBootstrap 中只出現了入口模塊的影子,那其他模塊又是如何加載的呢?我們順著 __webpack_require__("./src/index.js") 細看加載函數的內部邏輯:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
function __webpack_require__(moduleId) { // 重復加載則利用緩存 if (installedModules[moduleId]) { return installedModules[moduleId].exports; } // 如果是第一次加載,則初始化模塊對象,并緩存 var module = installedModules[moduleId] = { i: moduleId, // 模塊 ID l: false , // 模塊加載標識 exports: {} // 模塊導出對象 }; /** * 執行模塊 * @param module.exports -- 模塊導出對象引用,改變模塊包裹函數內部的 this 指向 * @param module -- 當前模塊對象引用 * @param module.exports -- 模塊導出對象引用 * @param __webpack_require__ -- 用于在模塊中加載其他模塊 */ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); // 模塊加載標識置為已加載 module.l = true ; // 返回當前模塊的導出對象引用 return module.exports; } |
首先,加載函數使用了閉包變量 installedModules,用來將已加載過的模塊保存在內存中。 接著是初始化模塊對象,并把它掛載到緩存里。然后是模塊的執行過程,加載入口文件時 modules[moduleId] 其實就是./src/index.js對應的模塊函數。執行模塊函數前傳入了跟模塊相關的幾個實參,讓模塊可以導出內容,以及加載其他模塊的導出。最后標識該模塊加載完成,返回模塊的導出內容。
根據 __webpack_require__ 的緩存和導出邏輯,我們得知在整個 IIFE 運行過程中,加載已緩存的模塊時,都會直接返回installedModules[moduleId].exports,換句話說,相同的模塊只有在第一次引用的時候才會執行模塊本身。
模塊執行函數
__webpack_require__中通過 modules[moduleId].call() 運行了模塊執行函數,下面我們就進入到webpackBootstrap 的參數部分,看看模塊的執行函數。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
/*** 入口模塊 ./src/index.js ***/ "./src/index.js" : ( function (module, __webpack_exports__, __webpack_require__) { "use strict" ; // 用于區分 ES 模塊和其他模塊規范,不影響理解 demo,戰略跳過。 __webpack_require__.r(__webpack_exports__); /* harmony import */ // 源模塊代碼中,`import {plus} from './utils/math.js';` 語句被 loader 解析轉化。 // 加載 "./src/utils/math.js" 模塊, var _utils_math_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__( "./src/utils/math.js" ); console.log( 'Hello webpack!' ); console.log( '1 + 2: ' , Object(_utils_math_js__WEBPACK_IMPORTED_MODULE_0__[ "plus" ])(1, 2)); }), "./src/utils/math.js" : ( function (module, __webpack_exports__, __webpack_require__) { "use strict" ; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ // 源模塊代碼中,`export` 語句被 loader 解析轉化。 __webpack_require__.d(__webpack_exports__, "plus" , function () { return plus; }); const plus = (a, b) => { return a + b; }; }) |
執行順序是:入口模塊 -> 工具模塊 -> 入口模塊。入口模塊中首先就通過 __webpack_require__("./src/utils/math.js") 拿到了工具模塊的 exports 對象。再看工具模塊,ES 導出語法轉化成了__webpack_require__.d(__webpack_exports__, [key], [getter]),而 __webpack_require__.d 函數的定義在 webpackBootstrap 內:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// 定義 exports 對象導出的屬性。 __webpack_require__.d = function (exports, name, getter) { // 如果 exports (不含原型鏈上)沒有 [name] 屬性,定義該屬性的 getter。 if (!__webpack_require__.o(exports, name)) { Object.defineProperty(exports, name, { enumerable: true , get: getter }); } }; // 包裝 Object.prototype.hasOwnProperty 函數。 __webpack_require__.o = function (object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; |
可見 __webpack_require__.d 其實就是 Object.defineProperty 的簡單包裝.
引用工具模塊導出的變量后,入口模塊再執行它剩余的部分。至此,Webpack 基本的模塊執行過程就結束了。
好了,我們用流程圖總結一下Webpack 模塊的加載思路:
異步加載
有上面的打包我們發現將不同的打包進一個 main.js 文件。main.js 會集中消耗太多網絡資源,導致用戶需要等待很久才可以開始與網頁交互。
一般的解決方式是:根據需求降低首次加載文件的體積,在需要時(如切換前端路由器,交互事件回調)異步加載其他文件并使用其中的模塊。
Webpack 推薦用 ES import() 規范來異步加載模塊,我們根據 ES 規范修改一下入口模塊的 import 方式,讓其能夠異步加載模塊:
src/index.js
1
2
3
4
5
6
|
console.log( 'Hello webpack!' ); window.setTimeout(() => { import( './utils/math' ).then(mathUtil => { console.log( '1 + 2: ' + mathUtil.plus(1, 2)); }); }, 2000); |
工具模塊(src/utils/math.js)依然不變,在webpack 配置里,我們指定一下資源文件的公共資源路徑(publicPath),后面的探索過程中會遇到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
const path = require( 'path' ); const MyPlugin = require( './src/MyPlugin.js' ) module.exports = { mode: 'development' , devtool: 'source-map' , entry: './src/index.js' , output: { path: path.resolve(__dirname, 'dist' ), publicPath: '/dist/' }, plugins:[ new MyPlugin() ] }; |
接著執行一下打包,可以看到除了 dist/main.js 外,又多了一個 dist/0.js ./src/utils/math.js。模塊從main chunk 遷移到了 0 chunk 中。而與 demo1 不同的是,main chunk 中添加了一些用于異步加載的代碼,我們概覽一下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
// webpackBootstrap ( function (modules) { // 加載其他 chunk 后的回調函數 function webpackJsonpCallback(data) { // ... } // ... // 用于緩存 chunk 的加載狀態,0 為已加載 var installedChunks = { "main" : 0 }; // 拼接 chunk 的請求地址 function jsonpScriptSrc(chunkId) { // ... } // 同步 require 函數,內容不變 function __webpack_require__(moduleId) { // ... } // 異步加載 chunk,返回封裝加載過程的 promise __webpack_require__.e = function requireEnsure(chunkId) { // ... } // ... // defineProperty 的包裝,內容不變 __webpack_require__.d = function (exports, name, getter) {} // ... // 根據配置文件確定的 publicPath __webpack_require__.p = "/dist/" ; /**** JSONP 初始化 ****/ 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; /**** JSONP 初始化 ****/ return __webpack_require__(__webpack_require__.s = "./src/index.js" ); })({ "./src/index.js" : ( function (module, exports, __webpack_require__) { document.write( 'Hello webpack!\n' ); window.setTimeout(() => { __webpack_require__.e( /*! import() */ 0).then(__webpack_require__.bind( null , /*! ./utils/math */ "./src/utils/math.js" )).then(mathUtil => { console.log( '1 + 2: ' + mathUtil.plus(1, 2)); }); }, 2000); }) }) |
可以看到 webpackBootstrap 的函數體部分增加了一些內容,參數部分移除了"./src/utils/math.js"模塊。跟著包裹函數的執行順序,我們先聚焦到「JSONP 初始化」部分:
1
2
3
4
5
6
7
8
9
10
11
12
|
// 存儲 jsonp 的數組,首次運行為 [] var jsonpArray = window[ "webpackJsonp" ] = window[ "webpackJsonp" ] || []; // 保存 jsonpArray 的 push 函數,首次運行為 Array.prototype.push var oldJsonpFunction = jsonpArray.push.bind(jsonpArray); // 將 jsonpArray 的 push 重寫為 webpackJsonpCallback (加載其他 chunk 后的回調函數) jsonpArray.push = webpackJsonpCallback; // 將 jsonpArray 重置為正常數組,push 重置為 Array.prototype.push jsonpArray = jsonpArray.slice(); // 由于 jsonpArray 為 [],不做任何事 for ( var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); // Array.prototype.push var parentJsonpFunction = oldJsonpFunction; |
初始化結束后,變化就是 window 上掛載了一個 webpackJsonp 數組,它的值為[];此外,這個數組的 push 被改寫為 webpackJsonpCallback 函數,我們在后面會提到這些準備工作的作用。
接著是 __webpack_require__入口模塊,由于 __webpack_require__ 函數沒有改變,我們繼續觀察入口模塊執行函數有了什么變化。
顯然,import('../utils/math.js') 被轉化為__webpack_require__.e(0).then(__webpack_require__.bind(null, "./src/utils/math.js"))。0 是 ./src/utils/math.js 所在 chunk 的id,「同步加載模塊」的邏輯拆分成了「先加載 chunk,完成后再加載模塊」。
我們翻到 __webpack_require__.e的定義位置:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
|
__webpack_require__.e = function requireEnsure(chunkId) { var promises = []; // installedChunks 是在 webpackBootstrap 中維護的 chunk 緩存 var installedChunkData = installedChunks[chunkId]; // chunk 未加載 if (installedChunkData !== 0) { // installedChunkData 為 promise 表示 chunk 加載中 if (installedChunkData) { promises.push(installedChunkData[2]); } else { /*** 首次加載 chunk: ***/ // 初始化 promise 對象 var promise = new Promise( function (resolve, reject) { installedChunkData = installedChunks[chunkId] = [resolve, reject]; }); promises.push(installedChunkData[2] = promise); // 創建 script 標簽加載 chunk var head = document.getElementsByTagName( 'head' )[0]; var script = document.createElement( 'script' ); var onScriptComplete; // ... 省略一些 script 屬性設置 // src 根據 publicPath 和 chunkId 拼接 script.src = jsonpScriptSrc(chunkId); // 加載結束回調函數,處理 script 加載完成、加載超時、加載失敗的情況 onScriptComplete = function (event) { script.onerror = script.onload = null ; // 避免 IE 內存泄漏問題 clearTimeout(timeout); var chunk = installedChunks[chunkId]; // 處理 script 加載完成,但 chunk 沒有加載完成的情況 if (chunk !== 0) { // chunk 加載中 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; // reject(error) chunk[1](error); } // 統一將沒有加載的 chunk 標記為未加載 installedChunks[chunkId] = undefined; } }; // 設置 12 秒超時時間 var timeout = setTimeout( function (){ onScriptComplete({ type: 'timeout' , target: script }); }, 120000); script.onerror = script.onload = onScriptComplete; head.appendChild(script); /*** 首次加載 chunk ***/ } } return Promise.all(promises); }; |
看起來有點長,我們一步步剖析,先從第一行和最后一行來看,整個函數將異步加載的過程封裝到了 promise 中,最終導出。
接著從第二行開始,installedChunkData 從緩存中取值,顯然首次加載 chunk 時此處是 undefined。接下來,installedChunkData 的 undefined 值觸發了第一層 if 語句的判斷條件。緊接著進行到第二層 if 語句,此時根據判斷條件走入 else塊,這里 if 塊里的內容我們先戰略跳過,else里主要有兩塊內容,一是 chunk 腳本加載過程,這個過程創建了一個 script 標簽,使其請求 chunk所在地址并執行chunk 內容;二是初始化 promise ,并用 promis 控制 chunk 文件加載過程。
不過,我們只在這段 else 代碼塊中找到了 reject 的使用處,也就是在 chunk 加載異常時chunk[1](error) 的地方,但并沒發現更重要的 resolve的使用地點,僅僅是把 resolve 掛在了緩存上(installedChunks[chunkId] = [resolve, reject])。
這里的 chunk 文件加載下來會發生什么呢?讓我們打開dist/0.js一探究竟:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
(window[ "webpackJsonp" ] = window[ "webpackJsonp" ] || []).push([[0], { "./src/utils/math.js" : ( function (module, __webpack_exports__, __webpack_require__) { "use strict" ; __webpack_require__.r(__webpack_exports__); /* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, "plus" , function () { return plus; }); const plus = (a, b) => { return a + b; }; }) }]); |
我們發現了:
久違的 ./src/utils/math.js 模塊
window["webpackJsonp"] 數組的使用地點
這段代碼開始執行,把異步加載相關的 chunk id 與模塊傳給push 函數。而前面已經提到過,window["webpackJsonp"]數組的 push 函數已被重寫為 webpackJsonpCallback 函數,它的定義位置在 webpackBootstrap 中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
function webpackJsonpCallback(data) { var chunkIds = data[0]; var moreModules = data[1]; // then flag all "chunkIds" as loaded and fire callback var moduleId, chunkId, i = 0, resolves = []; // 將 chunk 標記為已加載 for (;i < chunkIds.length; i++) { chunkId = chunkIds[i]; if (installedChunks[chunkId]) { resolves.push(installedChunks[chunkId][0]); } installedChunks[chunkId] = 0; } // 把 "moreModules" 加到 webpackBootstrap 中的 modules 閉包變量中。 for (moduleId in moreModules) { if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { modules[moduleId] = moreModules[moduleId]; } } // parentJsonpFunction 是 window["webpackJsonp"] 的原生 push // 將 data 加入全局數組,緩存 chunk 內容 if (parentJsonpFunction) parentJsonpFunction(data); // 執行 resolve 后,加載 chunk 的 promise 狀態變為 resolved,then 內的函數開始執行。 while (resolves.length) { resolves.shift()(); } }; |
走進這個函數中,意味著異步加載的 chunk 內容已經拿到,這個時候我們要完成兩件事,一是讓依賴這次異步加載結果的模塊繼續執行,二是緩存加載結果。
關于第一點,我們回憶一下之前 __webpack_require__.e 的內容,此時 chunk 還處于「加載中」的狀態,也就是說對應的 installedChunks[chunkId] 的值此時為[resolve, reject, promise]。 而這里,chunk 已經加載,但 promise 還未決議,于是 webpackJsonpCallback 內部定義了一個 resolves 變量用來收集 installedChunks 上的 resolve 并執行它。`
接下來說到第二點,就要涉及幾個層面的緩存了。
首先是 chunk 層面,這里有兩個相關操作,操作一將 installedChunks[chunkId] 置為 0 可以讓 __webpack_require__.e在第二次加載同一 chunk 時返回一個立即決議的 promise(Promise.all([]));操作二將 chunk data 添加進 window["webpackJsonp"] 數組,可以在多入口模式時,方便地拿到已加載過的 chunk 緩存。通過以下代碼實現:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
/*** 緩存執行部分 ***/ var jsonpArray = window[ "webpackJsonp" ] = window[ "webpackJsonp" ] || []; // ... for ( var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]); var parentJsonpFunction = oldJsonpFunction; /*** 緩存執行部分 ***/ /*** 緩存添加部分 ***/ function webpackJsonpCallback(data) { //... // 此處的 parentJsonpFunction 是 window["webpackJsonp"] 數組的原生 push if (parentJsonpFunction) parentJsonpFunction(data); //... } /*** 緩存添加部分 ***/ |
而在 modules 層面,chunk 中的 moreModules 被合入入口文件的modules 中,可供下一個微任務中的 __webpack_require__ 同步加載模塊。
1
2
3
4
5
6
7
8
9
10
11
|
({ "./src/index.js" : ( function (module, exports, __webpack_require__) { console.log( 'Hello webpack!' ); window.setTimeout(() => { __webpack_require__.e(0).then(__webpack_require__.bind( null , "./src/utils/math.js" )).then(mathUtil => { console.log( '1 + 2: ' + mathUtil.plus(1, 2)); }); }, 2000); }) }); |
__webpack_require__.e(0)返回的 promise 決議后,__webpack_require__.bind(null, "./src/utils/math.js") 可以加載到chunk攜帶的模塊,并返回模塊作為下一個微任務函數的入參,接下來就是 Webpack Loader 翻譯過的其他業務代碼了。
現在讓我們把異步流程梳理一下:
更多Webpack的基礎知識請點擊下面的相關文章
原文鏈接:https://segmentfault.com/a/1190000019117897