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

語言: CN / TW / HK

highlight: zenburn theme: devui-blue


一、前言

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

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

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

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

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

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

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

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

文中所涉及到的代碼均放到個人 github 倉庫中:https://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萬現金大獎」