二十張圖片徹底講明白Webpack設計理念,以看懂為目的

語言: CN / TW / HK

highlight: zenburn theme: devui-blue


一、前言

本文是 從零到億系統性的建立前端構建知識體系✨ 中的第八篇。

Webpack 一直都是有些人的心魔,不清楚原理是什麼,不知道怎麼去配置,只會基本的 API 使用。它就像一個黑盒,讓部分開發者對它望而生畏。

而本節最大的作用,就是幫大家一點一點的消滅心魔。

大家之所以認為 Webpack 複雜,很大程度上是因為它依附著一套龐大的生態系統。其實 Webpack 的核心流程遠沒有我們想象中那麼複雜,甚至只需百來行程式碼就能完整復刻出來。

因此在學習過程中,我們應注重學習它本身的設計思想,不管是它的 Plugin 系統還是 Loader 系統都是建立於這套核心思想之上。所謂萬變不離其宗,一通百通。

在本文中,我將會從 Webpack 的整體流程出發,通篇採用結論先行、自頂向下的方式進行講解。在涉及到原理性的知識時,儘量採用圖文的方式輔以理解,注重實現思路注重設計思想

另外,如果在閱讀過程中感到吃力(很正常),可自行補一補 Webpack 專欄中前置性的知識,每一節均完全解耦,可放心食用:

不瞭解也沒關係,在本節中我都會一一講到。

文中所涉及到的程式碼均放到個人 github 倉庫中:http://github.com/noBaldAaa/hand-webpack

二、基本使用

初始化專案:

js npm init //初始化一個專案 yarn add webpack //安裝專案依賴 安裝完依賴後,根據以下目錄結構來新增對應的目錄和檔案: ├── node_modules ├── package-lock.json ├── package.json ├── webpack.config.js #配置檔案 ├── debugger.js #測試檔案 └── src # 原始碼目錄 |── index.js |── name.js └── age.js webpack.config.js js const path = require("path"); module.exports = { mode: "development", //防止程式碼被壓縮 entry: "./src/index.js", //入口檔案 output: { path: path.resolve(__dirname, "dist"), filename: "[name].js", }, devtool: "source-map", //防止干擾原始檔 }; src/index.js(本文不討論CommonJS 和 ES Module之間的引用關係,以CommonJS為準

js const name = require("./name"); const age = require("./age"); console.log("entry檔案列印作者資訊", name, age); src/name.js module.exports = "不要禿頭啊"; src/age.js module.exports = "99"; 檔案依賴關係:

image.png

Webpack 本質上是一個函式,它接受一個配置資訊作為引數,執行後返回一個 compiler 物件,呼叫 compiler 物件中的 run 方法就會啟動編譯。run 方法接受一個回撥,可以用來檢視編譯過程中的錯誤資訊或編譯資訊。

debugger.js ```js // const { webpack } = require("./webpack.js"); //後面自己手寫 const { webpack } = require("webpack"); const webpackOptions = require("./webpack.config.js"); const compiler = webpack(webpackOptions);

//開始編譯 compiler.run((err, stats) => { console.log(err); console.log( stats.toJson({ assets: true, //列印本次編譯產出的資源 chunks: true, //列印本次編譯產出的程式碼塊 modules: true, //列印本次編譯產出的模組 }) ); }); ``` 執行打包命令:

js node ./debugger.js 得到產出檔案 dist/main.js(先暫停三十秒讀一讀下面程式碼,命名經優化):

carbon (1).png

執行該檔案,得到結果:

entry檔案列印作者資訊 不要禿頭啊 99

三、核心思想

我們先來分析一下原始碼和構建產物之間的關係:

image.png

從圖中可以看出,入口檔案(src/index.js)被包裹在最後的立即執行函式中,而它所依賴的模組(src/name.jssrc/age.js)則被放進了 modules 物件中(modules 用於存放入口檔案的依賴模組key 值為依賴模組路徑,value 值為依賴模組原始碼)。

require 函式是 web 環境下 載入模組的方法( require 原本是 node環境 中內建的方法,瀏覽器並不認識 require,所以這裡需要手動實現一下),它接受模組的路徑為引數,返回模組匯出的內容。

要想弄清楚 Webpack 原理,那麼核心問題就變成了:如何將左邊的原始碼轉換成 dist/main.js 檔案?


核心思想:

  • 第一步:首先,根據配置資訊(webpack.config.js)找到入口檔案(src/index.js
  • 第二步:找到入口檔案所依賴的模組,並收集關鍵資訊:比如路徑、原始碼、它所依賴的模組等: js var modules = [ { id: "./src/name.js",//路徑 dependencies: [], //所依賴的模組 source: 'module.exports = "不要禿頭啊";', //原始碼 }, { id: "./src/age.js", dependencies: [], source: 'module.exports = "99";', }, { id: "./src/index.js", dependencies: ["./src/name.js", "./src/age.js"], source: 'const name = require("./src/name.js");\n' + 'const age = require("./src/age.js");\n' + 'console.log("entry檔案列印作者資訊", name, age);', }, ];
  • 第三步:根據上一步得到的資訊,生成最終輸出到硬碟中的檔案(dist): 包括 modules 物件、require 模版程式碼、入口執行檔案等

在這過程中,由於瀏覽器並不認識除 html、js、css 以外的檔案格式,所以我們還需要對原始檔進行轉換 —— Loader 系統

Loader 系統 本質上就是接收資原始檔,並對其進行轉換,最終輸出轉換後的檔案:

image.png

除此之外,打包過程中也有一些特定的時機需要處理,比如:

  • 在打包前需要校驗使用者傳過來的引數,判斷格式是否符合要求
  • 在打包過程中,需要知道哪些模組可以忽略編譯,直接引用 cdn 連結
  • 在編譯完成後,需要將輸出的內容插入到 html 檔案中
  • 在輸出到硬碟前,需要先清空 dist 資料夾
  • ......

這個時候需要一個可插拔的設計,方便給社群提供可擴充套件的介面 —— Plugin 系統

Plugin 系統 本質上就是一種事件流的機制,到了固定的時間節點就廣播特定的事件,使用者可以在事件內執行特定的邏輯,類似於生命週期:

image.png

這些設計也都是根據使用場景來的,只有理清需求後我們才能更好的理解它的設計思想。

四、架構設計

在理清楚核心思想後,剩下的就是對其進行一步步拆解。

上面提到,我們需要建立一套事件流的機制來管控整個打包過程,大致可以分為三個階段:

  • 打包開始前的準備工作
  • 打包過程中(也就是編譯階段)
  • 打包結束後(包含打包成功和打包失敗)

這其中又以編譯階段最為複雜,另外還考慮到一個場景:watch mode(當檔案變化時,將重新進行編譯),因此這裡最好將編譯階段(也就是下文中的compilation)單獨解耦出來。

Webpack 原始碼中,compiler 就像是一個大管家,它就代表上面說的三個階段,在它上面掛載著各種生命週期函式,而 compilation 就像專管伙食的廚師,專門負責編譯相關的工作,也就是打包過程中這個階段。畫個圖幫助大家理解:

image.png

大致架構定下後,那現在應該如何實現這套事件流呢?

這時候就需要藉助 Tapable 了!它是一個類似於 Node.js 中的 EventEmitter 的庫,但更專注於自定義事件的觸發和處理。通過 Tapable 我們可以註冊自定義事件,然後在適當的時機去執行自定義事件。

類比到 Vue 和 React 框架中的生命週期函式,它們就是到了固定的時間節點就執行對應的生命週期,tapable 做的事情就和這個差不多,我們可以通過它先註冊一系列的生命週期函式,然後在合適的時間點執行。

example 🌰:

```js const { SyncHook } = require("tapable"); //這是一個同步鉤子

//第一步:例項化鉤子函式,可以在這裡定義形參 const syncHook = new SyncHook(["author", "age"]);

//第二步:註冊事件1 syncHook.tap("監聽器1", (name, age) => { console.log("監聽器1:", name, age); });

//第二步:註冊事件2 syncHook.tap("監聽器2", (name) => { console.log("監聽器2", name); });

//第三步:註冊事件3 syncHook.tap("監聽器3", (name) => { console.log("監聽器3", name); }); //第三步:觸發事件,這裡傳的是實參,會被每一個註冊函式接收到 syncHook.call("不要禿頭啊", "99"); ``` 執行上面這段程式碼,得到結果:

監聽器1 不要禿頭啊 99 監聽器2 不要禿頭啊 監聽器3 不要禿頭啊

在 Webpack 中,就是通過 tapablecomilercompilation 上像這樣掛載著一系列生命週期 Hook,它就像是一座橋樑,貫穿著整個構建過程:

js class Compiler { constructor() { //它內部提供了很多鉤子 this.hooks = { run: new SyncHook(), //會在編譯剛開始的時候觸發此鉤子 done: new SyncHook(), //會在編譯結束的時候觸發此鉤子 }; } }

五、具體實現

整個實現過程大致分為以下步驟:

  • (1)搭建結構,讀取配置引數
  • (2)用配置引數物件初始化 Compiler 物件
  • (3)掛載配置檔案中的外掛
  • (4)執行 Compiler 物件的 run 方法開始執行編譯
  • (5)根據配置檔案中的 entry 配置項找到所有的入口
  • (6)從入口檔案出發,呼叫配置的 loader 規則,對各模組進行編譯
  • (7)找出此模組所依賴的模組,再對依賴模組進行編譯
  • (8)等所有模組都編譯完成後,根據模組之間的依賴關係,組裝程式碼塊 chunk
  • (9)把各個程式碼塊 chunk 轉換成一個一個檔案加入到輸出列表
  • (10)確定好輸出內容之後,根據配置的輸出路徑和檔名,將檔案內容寫入到檔案系統

5.1、搭建結構,讀取配置引數

根據 Webpack 的用法可以看出, Webpack 本質上是一個函式,它接受一個配置資訊作為引數,執行後返回一個 compiler 物件,呼叫 compiler 物件中的 run 方法就會啟動編譯。run 方法接受一個回撥,可以用來檢視編譯過程中的錯誤資訊或編譯資訊。

修改 debugger.js 中 webpack 的引用:

```js + const webpack = require("./webpack"); //手寫webpack const webpackOptions = require("./webpack.config.js"); //這裡一般會放配置資訊 const compiler = webpack(webpackOptions);

compiler.run((err, stats) => { console.log(err); console.log( stats.toJson({ assets: true, //列印本次編譯產出的資源 chunks: true, //列印本次編譯產出的程式碼塊 modules: true, //列印本次編譯產出的模組 }) ); }); ``` 搭建結構:

```js class Compiler { constructor() {}

run(callback) {} }

//第一步:搭建結構,讀取配置引數,這裡接受的是webpack.config.js中的引數 function webpack(webpackOptions) { const compiler = new Compiler() return compiler; } ``` 執行流程圖:

image.png

5.2、用配置引數物件初始化 Compiler 物件

上面提到過,Compiler 它就是整個打包過程的大管家,它裡面放著各種你可能需要的編譯資訊生命週期 Hook,而且是單例模式。

```js //Compiler其實是一個類,它是整個編譯過程的大管家,而且是單例模式 class Compiler { + constructor(webpackOptions) { + this.options = webpackOptions; //儲存配置資訊 + //它內部提供了很多鉤子 + this.hooks = { + run: new SyncHook(), //會在編譯剛開始的時候觸發此run鉤子 + done: new SyncHook(), //會在編譯結束的時候觸發此done鉤子 + }; + } }

//第一步:搭建結構,讀取配置引數,這裡接受的是webpack.config.js中的引數 function webpack(webpackOptions) { //第二步:用配置引數物件初始化 Compiler 物件 + const compiler = new Compiler(webpackOptions) return compiler; } ``` 執行流程圖:

image.png

5.3、掛載配置檔案中的外掛

先寫兩個自定義外掛配置到 webpack.config.js 中:一個在開始打包的時候執行,一個在打包完成後執行。

Webpack Plugin 其實就是一個普通的函式,在該函式中需要我們定製一個 apply 方法。當 Webpack 內部進行外掛掛載時會執行 apply 函式。我們可以在 apply 方法中訂閱各種生命週期鉤子,當到達對應的時間點時就會執行。

```js //自定義外掛WebpackRunPlugin class WebpackRunPlugin { apply(compiler) { compiler.hooks.run.tap("WebpackRunPlugin", () => { console.log("開始編譯"); }); } }

//自定義外掛WebpackDonePlugin class WebpackDonePlugin { apply(compiler) { compiler.hooks.done.tap("WebpackDonePlugin", () => { console.log("結束編譯"); }); } } ``` webpack.config.js

js + const { WebpackRunPlugin, WebpackDonePlugin } = require("./webpack"); module.exports = { //其他省略 + plugins: [new WebpackRunPlugin(), new WebpackDonePlugin()], };

外掛定義時必須要有一個 apply 方法,載入外掛其實執行 apply 方法。

js //第一步:搭建結構,讀取配置引數,這裡接受的是webpack.config.js中的引數 function webpack(webpackOptions) { //第二步:用配置引數物件初始化 `Compiler` 物件 const compiler = new Compiler(webpackOptions); //第三步:掛載配置檔案中的外掛 + const { plugins } = webpackOptions; + for (let plugin of plugins) { + plugin.apply(compiler); + } return compiler; }

執行流程圖:

image.png

5.4、執行Compiler物件的run方法開始執行編譯

重點來了!

在正式開始編譯前,我們需要先呼叫 Compiler 中的 run 鉤子,表示開始啟動編譯了;在編譯結束後,需要呼叫 done 鉤子,表示編譯完成。

```js //Compiler其實是一個類,它是整個編譯過程的大管家,而且是單例模式 class Compiler { constructor(webpackOptions) { //省略 }

  • compile(callback){
  • //
  • }

  • //第四步:執行Compiler物件的run方法開始執行編譯

  • run(callback) {
  • this.hooks.run.call(); //在編譯前觸發run鉤子執行,表示開始啟動編譯了
  • const onCompiled = () => {
  • this.hooks.done.call(); //當編譯成功後會觸發done這個鉤子執行
  • };
  • this.compile(onCompiled); //開始編譯,成功之後呼叫onCompiled } } `` 上面架構設計中提到過,編譯這個階段需要單獨解耦出來,通過Compilation來完成,定義Compilation` 大致結構:

```js class Compiler { //省略其他 run(callback) { //省略 }

compile(callback) { //雖然webpack只有一個Compiler,但是每次編譯都會產出一個新的Compilation, //這裡主要是為了考慮到watch模式,它會在啟動時先編譯一次,然後監聽檔案變化,如果發生變化會重新開始編譯 //每次編譯都會產出一個新的Compilation,代表每次的編譯結果 + let compilation = new Compilation(this.options); + compilation.build(callback); //執行compilation的build方法進行編譯,編譯成功之後執行回撥 } }

  • class Compilation {
  • constructor(webpackOptions) {
  • this.options = webpackOptions;
  • this.modules = []; //本次編譯所有生成出來的模組
  • this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊
  • this.assets = {}; //本次編譯產出的資原始檔
  • this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯
  • }

  • build(callback) {

  • //這裡開始做編譯工作,編譯成功執行callback
  • callback()
  • }
  • } ```

執行流程圖(點選可放大):

image.png

5.5、根據配置檔案中的entry配置項找到所有的入口

接下來就正式開始編譯了,邏輯均在 Compilation 中。

在編譯前我們首先需要知道入口檔案,而 入口的配置方式 有多種,可以配置成字串,也可以配置成一個物件,這一步驟就是為了統一配置資訊的格式,然後找出所有的入口(考慮多入口打包的場景)。

```js class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

build(callback) { //第五步:根據配置檔案中的entry配置項找到所有的入口 + let entry = {}; + if (typeof this.options.entry === "string") { + entry.main = this.options.entry; //如果是單入口,將entry:"xx"變成{main:"xx"},這裡需要做相容 + } else { + entry = this.options.entry; + }

//編譯成功執行callback
callback()

} } ```

執行流程圖(點選可放大):

image.png

5.6、從入口檔案出發,呼叫配置的loader規則,對各模組進行編譯

Loader 本質上就是一個函式,接收資原始檔或者上一個 Loader 產生的結果作為入參,最終輸出轉換後的結果。

寫兩個自定義 Loader 配置到 webpack.config.js 中:

```js const loader1 = (source) => { return source + "//給你的程式碼加點註釋:loader1"; };

const loader2 = (source) => { return source + "//給你的程式碼加點註釋:loader2"; }; **webpack.config.js**js const { loader1, loader2 } = require("./webpack"); module.exports = { //省略其他 module: { rules: [ { test: /.js$/, use: [loader1, loader2], }, ], }, }; ```

這一步驟將從入口檔案出發,然後查找出對應的 Loader 對原始碼進行翻譯和替換。

主要有三個要點:

  • (6.1)把入口檔案的絕對路徑新增到依賴陣列(this.fileDependencies)中,記錄此次編譯依賴的模組
  • (6.2)得到入口模組的的 module 物件 (裡面放著該模組的路徑、依賴模組、原始碼等)
  • (6.2.1)讀取模組內容,獲取原始碼
  • (6.2.2)建立模組物件
  • (6.2.3)找到對應的 Loader 對原始碼進行翻譯和替換
  • (6.3)將生成的入口檔案 module 物件 push 進 this.modules

6.1:把入口檔案的絕對路徑新增到依賴陣列中,記錄此次編譯依賴的模組

這裡因為要獲取入口檔案的絕對路徑,考慮到作業系統的相容性問題,需要將路徑的 \ 都替換成 /

```js //將\替換成/ function toUnixPath(filePath) { return filePath.replace(/\/g, "/"); }

const baseDir = toUnixPath(process.cwd()); //獲取工作目錄,在哪裡執行命令就獲取哪裡的目錄,這裡獲取的也是跟作業系統有關係,要替換成/

class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

build(callback) { //第五步:根據配置檔案中的entry配置項找到所有的入口 let entry = {}; if (typeof this.options.entry === "string") { entry.main = this.options.entry; //如果是單入口,將entry:"xx"變成{main:"xx"},這裡需要做相容 } else { entry = this.options.entry; } + //第六步:從入口檔案出發,呼叫配置的 loader 規則,對各模組進行編譯 + for (let entryName in entry) { + //entryName="main" entryName就是entry的屬性名,也將會成為程式碼塊的名稱 + let entryFilePath = path.posix.join(baseDir, entry[entryName]); //path.posix為了解決不同作業系統的路徑分隔符,這裡拿到的就是入口檔案的絕對路徑 + //6.1 把入口檔案的絕對路徑新增到依賴陣列(this.fileDependencies)中,記錄此次編譯依賴的模組 + this.fileDependencies.push(entryFilePath); + }

//編譯成功執行callback
callback()

} } ```

6.2.1:讀取模組內容,獲取原始碼

```js class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

  • //當編譯模組的時候,name:這個模組是屬於哪個程式碼塊chunk的,modulePath:模組絕對路徑
  • buildModule(name, modulePath) {
  • //6.2.1 讀取模組內容,獲取原始碼
  • let sourceCode = fs.readFileSync(modulePath, "utf8"); +
  • return {};
  • }

build(callback) { //第五步:根據配置檔案中的entry配置項找到所有的入口 //程式碼省略... //第六步:從入口檔案出發,呼叫配置的 loader 規則,對各模組進行編譯 for (let entryName in entry) { //entryName="main" entryName就是entry的屬性名,也將會成為程式碼塊的名稱 let entryFilePath = path.posix.join(baseDir, entry[entryName]); //path.posix為了解決不同作業系統的路徑分隔符,這裡拿到的就是入口檔案的絕對路徑 //6.1 把入口檔案的絕對路徑新增到依賴陣列(this.fileDependencies)中,記錄此次編譯依賴的模組 this.fileDependencies.push(entryFilePath); //6.2 得到入口模組的的 module 物件 (裡面放著該模組的路徑、依賴模組、原始碼等) + let entryModule = this.buildModule(entryName, entryFilePath); }

//編譯成功執行callback
callback()

} } ```

6.2.2:建立模組物件

```js class Compilation { //省略其他

//當編譯模組的時候,name:這個模組是屬於哪個程式碼塊chunk的,modulePath:模組絕對路徑 buildModule(name, modulePath) { //6.2.1 讀取模組內容,獲取原始碼 let sourceCode = fs.readFileSync(modulePath, "utf8"); //buildModule最終會返回一個modules模組物件,每個模組都會有一個id,id是相對於根目錄的相對路徑 + let moduleId = "./" + path.posix.relative(baseDir, modulePath); //模組id:從根目錄出發,找到與該模組的相對路徑(./src/index.js) + //6.2.2 建立模組物件 + let module = { + id: moduleId, + names: [name], //names設計成陣列是因為代表的是此模組屬於哪個程式碼塊,可能屬於多個程式碼塊 + dependencies: [], //它依賴的模組 + _source: "", //該模組的程式碼資訊 + }; + return module; }

build(callback) { //省略 } } ```

6.2.3:找到對應的 Loader 對原始碼進行翻譯和替換 ```js class Compilation { //省略其他

//當編譯模組的時候,name:這個模組是屬於哪個程式碼塊chunk的,modulePath:模組絕對路徑 buildModule(name, modulePath) { //6.2.1 讀取模組內容,獲取原始碼 let sourceCode = fs.readFileSync(modulePath, "utf8"); //buildModule最終會返回一個modules模組物件,每個模組都會有一個id,id是相對於根目錄的相對路徑 let moduleId = "./" + path.posix.relative(baseDir, modulePath); //模組id:從根目錄出發,找到與該模組的相對路徑(./src/index.js) //6.2.2 建立模組物件 let module = { id: moduleId, names: [name], //names設計成陣列是因為代表的是此模組屬於哪個程式碼塊,可能屬於多個程式碼塊 dependencies: [], //它依賴的模組 _source: "", //該模組的程式碼資訊 }; //6.2.3 找到對應的 Loader 對原始碼進行翻譯和替換 + let loaders = []; + let { rules = [] } = this.options.module; + rules.forEach((rule) => { + let { test } = rule; + //如果模組的路徑和正則匹配,就把此規則對應的loader新增到loader陣列中 + if (modulePath.match(test)) { + loaders.push(...rule.use); + } + });

  • //自右向左對模組進行轉譯
  • sourceCode = loaders.reduceRight((code, loader) => {
  • return loader(code);
  • }, sourceCode);

    return module; }

build(callback) { //省略 } } ```

6.3:將生成的入口檔案 module 物件 push 進 this.modules 中 ```js class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

buildModule(name, modulePath) { //省略其他 }

build(callback) { //第五步:根據配置檔案中的entry配置項找到所有的入口 //省略其他 //第六步:從入口檔案出發,呼叫配置的 loader 規則,對各模組進行編譯 for (let entryName in entry) { //entryName="main" entryName就是entry的屬性名,也將會成為程式碼塊的名稱 let entryFilePath = path.posix.join(baseDir, entry[entryName]); //path.posix為了解決不同作業系統的路徑分隔符,這裡拿到的就是入口檔案的絕對路徑 //6.1 把入口檔案的絕對路徑新增到依賴陣列(this.fileDependencies)中,記錄此次編譯依賴的模組 this.fileDependencies.push(entryFilePath); //6.2 得到入口模組的的 module 物件 (裡面放著該模組的路徑、依賴模組、原始碼等) let entryModule = this.buildModule(entryName, entryFilePath); + //6.3 將生成的入口檔案 module 物件 push 進 this.modules 中 + this.modules.push(entryModule); } //編譯成功執行callback callback() } } ``` 執行流程圖(點選可放大):

image.png

5.7、找出此模組所依賴的模組,再對依賴模組進行編譯

該步驟是整體流程中最為複雜的,一遍看不懂沒關係,可以先理解思路。

該步驟經過細化可以將其拆分成十個小步驟:

  • (7.1):先把原始碼編譯成 AST
  • (7.2):在 AST 中查詢 require 語句,找出依賴的模組名稱和絕對路徑
  • (7.3):將依賴模組的絕對路徑 push 到 this.fileDependencies
  • (7.4):生成依賴模組的模組 id
  • (7.5):修改語法結構,把依賴的模組改為依賴模組 id
  • (7.6):將依賴模組的資訊 push 到該模組的 dependencies 屬性中
  • (7.7):生成新程式碼,並把轉譯後的原始碼放到 module._source 屬性上
  • (7.8):對依賴模組進行編譯(對 module 物件中的 dependencies 進行遞迴執行 buildModule
  • (7.9):對依賴模組編譯完成後得到依賴模組的 module 物件,push 到 this.modules
  • (7.10):等依賴模組全部編譯完成後,返回入口模組的 module 物件

```js + const parser = require("@babel/parser"); + let types = require("@babel/types"); //用來生成或者判斷節點的AST語法樹的節點 + const traverse = require("@babel/traverse").default; + const generator = require("@babel/generator").default;

//獲取檔案路徑 + function tryExtensions(modulePath, extensions) { + if (fs.existsSync(modulePath)) { + return modulePath; + } + for (let i = 0; i < extensions?.length; i++) { + let filePath = modulePath + extensions[i]; + if (fs.existsSync(filePath)) { + return filePath; + } + } + throw new Error(無法找到${modulePath}); + }

class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

//當編譯模組的時候,name:這個模組是屬於哪個程式碼塊chunk的,modulePath:模組絕對路徑 buildModule(name, modulePath) { //省略其他 //6.2.1 讀取模組內容,獲取原始碼 //6.2.2 建立模組物件 //6.2.3 找到對應的 Loader 對原始碼進行翻譯和替換

//自右向左對模組進行轉譯
sourceCode = loaders.reduceRight((code, loader) => {
  return loader(code);
}, sourceCode);

//通過loader翻譯後的內容一定得是js內容,因為最後得走我們babel-parse,只有js才能成編譯AST
//第七步:找出此模組所依賴的模組,再對依賴模組進行編譯
  • //7.1:先把原始碼編譯成 AST
  • let ast = parser.parse(sourceCode, { sourceType: "module" });
  • traverse(ast, {
  • CallExpression: (nodePath) => {
  • const { node } = nodePath;
  • //7.2:在 AST 中查詢 require 語句,找出依賴的模組名稱和絕對路徑
  • if (node.callee.name === "require") {
  • let depModuleName = node.arguments[0].value; //獲取依賴的模組
  • let dirname = path.posix.dirname(modulePath); //獲取當前正在編譯的模所在的目錄
  • let depModulePath = path.posix.join(dirname, depModuleName); //獲取依賴模組的絕對路徑
  • let extensions = this.options.resolve?.extensions || [ ".js" ]; //獲取配置中的extensions
  • depModulePath = tryExtensions(depModulePath, extensions); //嘗試新增字尾,找到一個真實在硬碟上存在的檔案
  • //7.3:將依賴模組的絕對路徑 push 到 this.fileDependencies
  • this.fileDependencies.push(depModulePath);
  • //7.4:生成依賴模組的模組 id
  • let depModuleId = "./" + path.posix.relative(baseDir, depModulePath);
  • //7.5:修改語法結構,把依賴的模組改為依賴模組 id require("./name")=>require("./src/name.js")
  • node.arguments = [types.stringLiteral(depModuleId)];
  • //7.6:將依賴模組的資訊 push 到該模組的 dependencies 屬性中
  • module.dependencies.push({ depModuleId, depModulePath });
  • }
  • },
  • });

  • //7.7:生成新程式碼,並把轉譯後的原始碼放到 module._source 屬性上

  • let { code } = generator(ast);
  • module._source = code;
  • //7.8:對依賴模組進行編譯(對 module 物件中的 dependencies 進行遞迴執行 buildModule
  • module.dependencies.forEach(({ depModuleId, depModulePath }) => {
  • //考慮到多入口打包 :一個模組被多個其他模組引用,不需要重複打包
  • let existModule = this.modules.find((item) => item.id === depModuleId);
  • //如果modules裡已經存在這個將要編譯的依賴模組了,那麼就不需要編譯了,直接把此程式碼塊的名稱新增到對應模組的names欄位裡就可以
  • if (existModule) {
  • //names指的是它屬於哪個程式碼塊chunk
  • existModule.names.push(name);
  • } else {
  • //7.9:對依賴模組編譯完成後得到依賴模組的 module 物件,push 到 this.modules
  • let depModule = this.buildModule(name, depModulePath);
  • this.modules.push(depModule);
  • }
  • });
  • //7.10:等依賴模組全部編譯完成後,返回入口模組的 module 物件
  • return module; }
    //省略其他 } ```

執行流程圖(點選可放大):

image.png

5.8、等所有模組都編譯完成後,根據模組之間的依賴關係,組裝程式碼塊 chunk

現在,我們已經知道了入口模組和它所依賴模組的所有資訊,可以去生成對應的程式碼塊了。

一般來說,每個入口檔案會對應一個程式碼塊chunk,每個程式碼塊chunk裡面會放著本入口模組和它依賴的模組,這裡暫時不考慮程式碼分割。

```js class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

buildModule(name, modulePath) { //省略其他 }

build(callback) { //第五步:根據配置檔案中的entry配置項找到所有的入口 //省略其他 //第六步:從入口檔案出發,呼叫配置的 loader 規則,對各模組進行編譯 for (let entryName in entry) { //entryName="main" entryName就是entry的屬性名,也將會成為程式碼塊的名稱 let entryFilePath = path.posix.join(baseDir, entry[entryName]); //path.posix為了解決不同作業系統的路徑分隔符,這裡拿到的就是入口檔案的絕對路徑 //6.1 把入口檔案的絕對路徑新增到依賴陣列(this.fileDependencies)中,記錄此次編譯依賴的模組 this.fileDependencies.push(entryFilePath); //6.2 得到入口模組的的 module 物件 (裡面放著該模組的路徑、依賴模組、原始碼等) let entryModule = this.buildModule(entryName, entryFilePath); //6.3 將生成的入口檔案 module 物件 push 進 this.modules 中 this.modules.push(entryModule); //第八步:等所有模組都編譯完成後,根據模組之間的依賴關係,組裝程式碼塊 chunk(一般來說,每個入口檔案會對應一個程式碼塊chunk,每個程式碼塊chunk裡面會放著本入口模組和它依賴的模組) + let chunk = { + name: entryName, //entryName="main" 程式碼塊的名稱 + entryModule, //此程式碼塊對應的module的物件,這裡就是src/index.js 的module物件 + modules: this.modules.filter((item) => item.names.includes(entryName)), //找出屬於該程式碼塊的模組 + }; + this.chunks.push(chunk); } //編譯成功執行callback callback() } } ```

執行流程圖(點選可放大):

image.png

5.9、把各個程式碼塊 chunk 轉換成一個一個檔案加入到輸出列表

這一步需要結合配置檔案中的output.filename去生成輸出檔案的檔名稱,同時還需要生成執行時程式碼:

``js //生成執行時程式碼 + function getSource(chunk) { + return + (() => { + var modules = { + ${chunk.modules.map( + (module) => + "${module.id}": (module) => { + ${module._source} + } + + )}
+ }; + var cache = {}; + function require(moduleId) { + var cachedModule = cache[moduleId]; + if (cachedModule !== undefined) { + return cachedModule.exports; + } + var module = (cache[moduleId] = { + exports: {}, + }); + modulesmoduleId; + return module.exports; + } + var exports ={}; + ${chunk.entryModule._source} + })(); + `; + }

class Compilation { constructor(webpackOptions) { this.options = webpackOptions; this.modules = []; //本次編譯所有生成出來的模組 this.chunks = []; //本次編譯產出的所有程式碼塊,入口模組和依賴的模組打包在一起為程式碼塊 this.assets = {}; //本次編譯產出的資原始檔 this.fileDependencies = []; //本次打包涉及到的檔案,這裡主要是為了實現watch模式下監聽檔案的變化,檔案發生變化後會重新編譯 }

//當編譯模組的時候,name:這個模組是屬於哪個程式碼塊chunk的,modulePath:模組絕對路徑 buildModule(name, modulePath) { //省略 }

build(callback) { //第五步:根據配置檔案中的entry配置項找到所有的入口 //第六步:從入口檔案出發,呼叫配置的 loader 規則,對各模組進行編譯 for (let entryName in entry) { //省略 //6.1 把入口檔案的絕對路徑新增到依賴陣列(this.fileDependencies)中,記錄此次編譯依賴的模組 //6.2 得到入口模組的的 module 物件 (裡面放著該模組的路徑、依賴模組、原始碼等) //6.3 將生成的入口檔案 module 物件 push 進 this.modules 中 //第八步:等所有模組都編譯完成後,根據模組之間的依賴關係,組裝程式碼塊 chunk(一般來說,每個入口檔案會對應一個程式碼塊chunk,每個程式碼塊chunk裡面會放著本入口模組和它依賴的模組) }

//第九步:把各個程式碼塊 `chunk` 轉換成一個一個檔案加入到輸出列表
  • this.chunks.forEach((chunk) => {
  • let filename = this.options.output.filename.replace("[name]", chunk.name);
  • this.assets[filename] = getSource(chunk);
  • });

  • callback(

  • null,
  • {
  • chunks: this.chunks,
  • modules: this.modules,
  • assets: this.assets,
  • },
  • this.fileDependencies
  • ); } }

```

到了這裡,Compilation 的邏輯就走完了。

執行流程圖(點選可放大):

image.png

5.10、確定好輸出內容之後,根據配置的輸出路徑和檔名,將檔案內容寫入到檔案系統

該步驟就很簡單了,直接按照 Compilation 中的 this.status 物件將檔案內容寫入到檔案系統(這裡就是硬碟)。

```js class Compiler { constructor(webpackOptions) { this.options = webpackOptions; //儲存配置資訊 //它內部提供了很多鉤子 this.hooks = { run: new SyncHook(), //會在編譯剛開始的時候觸發此run鉤子 done: new SyncHook(), //會在編譯結束的時候觸發此done鉤子 }; }

compile(callback) { //省略 }

//第四步:執行Compiler物件的run方法開始執行編譯 run(callback) { this.hooks.run.call(); //在編譯前觸發run鉤子執行,表示開始啟動編譯了 const onCompiled = (err, stats, fileDependencies) => { + //第十步:確定好輸出內容之後,根據配置的輸出路徑和檔名,將檔案內容寫入到檔案系統(這裡就是硬碟) + for (let filename in stats.assets) { + let filePath = path.join(this.options.output.path, filename); + fs.writeFileSync(filePath, stats.assets[filename], "utf8"); + }

  • callback(err, {
  • toJson: () => stats,
  • });

    this.hooks.done.call(); //當編譯成功後會觸發done這個鉤子執行 }; this.compile(onCompiled); //開始編譯,成功之後呼叫onCompiled } } ```

執行流程圖(點選可放大):

image.png

完整流程圖

以上就是整個 Webpack 的執行流程圖,還是描述的比較清晰的,跟著一步步走看懂肯定沒問題!

image.png

執行 node ./debugger.js,通過我們手寫的 Webpack 進行打包,得到輸出檔案 dist/main.js

carbon.png

六、實現 watch 模式

看完上面的實現,有些小夥伴可能有疑問了:Compilation 中的 this.fileDependencies(本次打包涉及到的檔案)是用來做什麼的?為什麼沒有地方用到該屬性?

這裡其實是為了實現 Webpack 的 watch 模式:當檔案發生變更時將重新編譯。

思路:對 this.fileDependencies 裡面的檔案進行監聽,當檔案發生變化時,重新執行 compile 函式。

```js class Compiler { constructor(webpackOptions) { //省略 }

compile(callback) { //雖然webpack只有一個Compiler,但是每次編譯都會產出一個新的Compilation, //這裡主要是為了考慮到watch模式,它會在啟動時先編譯一次,然後監聽檔案變化,如果發生變化會重新開始編譯 //每次編譯都會產出一個新的Compilation,代表每次的編譯結果 let compilation = new Compilation(this.options); compilation.build(callback); //執行compilation的build方法進行編譯,編譯成功之後執行回撥 }

//第四步:執行Compiler物件的run方法開始執行編譯 run(callback) { this.hooks.run.call(); //在編譯前觸發run鉤子執行,表示開始啟動編譯了 const onCompiled = (err, stats, fileDependencies) => { //第十步:確定好輸出內容之後,根據配置的輸出路徑和檔名,將檔案內容寫入到檔案系統(這裡就是硬碟) for (let filename in stats.assets) { let filePath = path.join(this.options.output.path, filename); fs.writeFileSync(filePath, stats.assets[filename], "utf8"); }

  callback(err, {
    toJson: () => stats,
  });
  • fileDependencies.forEach((fileDependencie) => {
  • fs.watch(fileDependencie, () => this.compile(onCompiled));
  • });

    this.hooks.done.call(); //當編譯成功後會觸發done這個鉤子執行 }; this.compile(onCompiled); //開始編譯,成功之後呼叫onCompiled } } ``` 相信看到這裡,你一定也理解了 compile 和 Compilation 的設計,都是為了解耦和複用呀。

七、總結

本文從 Webpack 的基本使用和構建產物出發,從思想和架構兩方面深度剖析了 Webpack 的設計理念。最後在程式碼實現階段,通過百來行程式碼手寫了 Webpack 的整體流程,儘管它只能對檔案進行打包,還缺少很多功能,但麻雀雖小,卻也五臟俱全。

相信讀完本章,你也一定已經克服 Webpack 的恐懼了!

什麼?實現簡易版 Webpack 還不夠你塞牙縫?我這裡還有跟原始碼一比一實現的版本哦,均放在文章頭部的 github 連結中,還不快去挑戰一下自己的軟肋😉😉😉。

推薦閱讀

  1. 從零到億系統性的建立前端構建知識體系✨
  2. 我是如何帶領團隊從零到一建立前端規範的?🎉🎉🎉
  3. 線上崩了?一招教你快速定位問題!
  4. 【中級/高階前端】為什麼我建議你一定要讀一讀 Tapable 原始碼?
  5. 前端工程化基石 -- AST(抽象語法樹)以及AST的廣泛應用🔥
  6. Webpack深度進階:兩張圖徹底講明白熱更新原理!
  7. 【萬字長文|趣味圖解】徹底弄懂Webpack中的Loader機制
  8. 學會這些自定義hooks,讓你摸魚時間再翻一倍🐟🐟
  9. 淺析前端異常及降級處理
  10. 前端重新部署後,領導跟我說頁面崩潰了...

本文正在參加「金石計劃 . 瓜分6萬現金大獎」