二十張圖片徹底講明白Webpack設計理念,以看懂為目的
highlight: zenburn theme: devui-blue
一、前言
本文是 從零到億系統性的建立前端構建知識體系✨ 中的第八篇。
Webpack 一直都是有些人的心魔,不清楚原理是什麼,不知道怎麼去配置,只會基本的 API 使用。它就像一個黑盒,讓部分開發者對它望而生畏。
而本節最大的作用,就是幫大家一點一點的消滅心魔。
大家之所以認為 Webpack 複雜,很大程度上是因為它依附著一套龐大的生態系統。其實 Webpack 的核心流程遠沒有我們想象中那麼複雜,甚至只需百來行程式碼就能完整復刻出來。
因此在學習過程中,我們應注重學習它本身的設計思想,不管是它的 Plugin 系統
還是 Loader 系統
,都是建立於這套核心思想之上。所謂萬變不離其宗,一通百通。
在本文中,我將會從 Webpack 的整體流程出發,通篇採用結論先行、自頂向下的方式進行講解。在涉及到原理性的知識時,儘量採用圖文的方式輔以理解,注重實現思路
,注重設計思想
。
另外,如果在閱讀過程中感到吃力(很正常),可自行補一補 Webpack 專欄中前置性的知識,每一節均完全解耦,可放心食用:
不瞭解也沒關係,在本節中我都會一一講到。
- 從構建產物洞悉模組化原理
- 【Webpack】非同步載入(懶載入)原理
- 前端工程化基石 -- AST(抽象語法樹)以及AST的廣泛應用
- 【萬字長文|趣味圖解】徹底弄懂Webpack中的Loader機制
- 【Webpack Plugin】寫了個外掛跟喜歡的女生表白,結果......
- 中級/高階前端】為什麼我建議你一定要讀一讀 Tapable 原始碼?
文中所涉及到的程式碼均放到個人 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";
檔案依賴關係:
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(先暫停三十秒讀一讀下面程式碼,命名經優化):
執行該檔案,得到結果:
entry檔案列印作者資訊 不要禿頭啊 99
三、核心思想
我們先來分析一下原始碼和構建產物之間的關係:
從圖中可以看出,入口檔案(src/index.js
)被包裹在最後的立即執行函式中,而它所依賴的模組(src/name.js
、src/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 系統 本質上就是接收資原始檔,並對其進行轉換,最終輸出轉換後的檔案:
除此之外,打包過程中也有一些特定的時機需要處理,比如:
- 在打包前需要校驗使用者傳過來的引數,判斷格式是否符合要求
- 在打包過程中,需要知道哪些模組可以忽略編譯,直接引用 cdn 連結
- 在編譯完成後,需要將輸出的內容插入到 html 檔案中
- 在輸出到硬碟前,需要先清空 dist 資料夾
- ......
這個時候需要一個可插拔的設計,方便給社群提供可擴充套件的介面 —— Plugin 系統
。
Plugin 系統 本質上就是一種事件流的機制,到了固定的時間節點就廣播特定的事件,使用者可以在事件內執行特定的邏輯,類似於生命週期:
這些設計也都是根據使用場景來的,只有理清需求後我們才能更好的理解它的設計思想。
四、架構設計
在理清楚核心思想後,剩下的就是對其進行一步步拆解。
上面提到,我們需要建立一套事件流的機制來管控整個打包過程,大致可以分為三個階段:
- 打包開始前的準備工作
- 打包過程中(也就是編譯階段)
- 打包結束後(包含打包成功和打包失敗)
這其中又以編譯階段最為複雜,另外還考慮到一個場景:watch mode(當檔案變化時,將重新進行編譯),因此這裡最好將編譯階段(也就是下文中的compilation
)單獨解耦出來。
在 Webpack 原始碼中,compiler
就像是一個大管家,它就代表上面說的三個階段,在它上面掛載著各種生命週期函式,而 compilation
就像專管伙食的廚師,專門負責編譯相關的工作,也就是打包過程中
這個階段。畫個圖幫助大家理解:
大致架構定下後,那現在應該如何實現這套事件流呢?
這時候就需要藉助 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 中,就是通過 tapable
在 comiler
和 compilation
上像這樣掛載著一系列生命週期 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; } ``` 執行流程圖:
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;
}
```
執行流程圖:
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;
}
執行流程圖:
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()
- }
- } ```
執行流程圖(點選可放大):
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()
} } ```
執行流程圖(點選可放大):
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()
}
}
```
執行流程圖(點選可放大):
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;
}
//省略其他 } ```
執行流程圖(點選可放大):
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()
}
}
```
執行流程圖(點選可放大):
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
的邏輯就走完了。
執行流程圖(點選可放大):
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 } } ```
執行流程圖(點選可放大):
完整流程圖
以上就是整個 Webpack 的執行流程圖,還是描述的比較清晰的,跟著一步步走看懂肯定沒問題!
執行 node ./debugger.js
,通過我們手寫的 Webpack 進行打包,得到輸出檔案 dist/main.js:
六、實現 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 連結中,還不快去挑戰一下自己的軟肋😉😉😉。
推薦閱讀
- 從零到億系統性的建立前端構建知識體系✨
- 我是如何帶領團隊從零到一建立前端規範的?🎉🎉🎉
- 線上崩了?一招教你快速定位問題!
- 【中級/高階前端】為什麼我建議你一定要讀一讀 Tapable 原始碼?
- 前端工程化基石 -- AST(抽象語法樹)以及AST的廣泛應用🔥
- Webpack深度進階:兩張圖徹底講明白熱更新原理!
- 【萬字長文|趣味圖解】徹底弄懂Webpack中的Loader機制
- 學會這些自定義hooks,讓你摸魚時間再翻一倍🐟🐟
- 淺析前端異常及降級處理
- 前端重新部署後,領導跟我說頁面崩潰了...
本文正在參加「金石計劃 . 瓜分6萬現金大獎」