二十張圖片徹底講明白 Webpack 設(shè)計(jì)理念
一、前言
Webpack?一直都是有些人的心魔,不清楚原理是什么,不知道怎么去配置,只會(huì)基本的 API 使用。它就像一個(gè)黑盒,讓部分開發(fā)者對它望而生畏。
而本節(jié)最大的作用,就是幫大家一點(diǎn)一點(diǎn)的消滅心魔。
大家之所以認(rèn)為?Webpack?復(fù)雜,很大程度上是因?yàn)樗栏街惶嫶蟮纳鷳B(tài)系統(tǒng)。其實(shí) Webpack 的核心流程遠(yuǎn)沒有我們想象中那么復(fù)雜,甚至只需百來行代碼就能完整復(fù)刻出來。
因此在學(xué)習(xí)過程中,我們應(yīng)注重學(xué)習(xí)它本身的設(shè)計(jì)思想,不管是它的?Plugin 系統(tǒng)
還是?Loader 系統(tǒng)
,都是建立于這套核心思想之上。所謂萬變不離其宗,一通百通。
在本文中,我將會(huì)從 Webpack 的整體流程出發(fā),通篇采用結(jié)論先行、自頂向下的方式進(jìn)行講解。在涉及到原理性的知識(shí)時(shí),盡量采用圖文的方式輔以理解,注重實(shí)現(xiàn)思路
,注重設(shè)計(jì)思想
。
二、基本使用
初始化項(xiàng)目:
安裝完依賴后,根據(jù)以下目錄結(jié)構(gòu)來添加對應(yīng)的目錄和文件:
webpack.config.js
src/index.js(本文不討論CommonJS 和 ES Module之間的引用關(guān)系,以CommonJS為準(zhǔn)
)
src/name.js
src/age.js
文件依賴關(guān)系:

Webpack 本質(zhì)上是一個(gè)函數(shù),它接受一個(gè)配置信息作為參數(shù),執(zhí)行后返回一個(gè) compiler 對象,調(diào)用?compiler
?對象中的 run 方法就會(huì)啟動(dòng)編譯。run
?方法接受一個(gè)回調(diào),可以用來查看編譯過程中的錯(cuò)誤信息或編譯信息。
debugger.js
執(zhí)行打包命令:
得到產(chǎn)出文件?dist/main.js(先暫停三十秒讀一讀下面代碼,命名經(jīng)優(yōu)化):

運(yùn)行該文件,得到結(jié)果:
三、核心思想
我們先來分析一下源代碼和構(gòu)建產(chǎn)物之間的關(guān)系:

從圖中可以看出,入口文件(src/index.js
)被包裹在最后的立即執(zhí)行函數(shù)中,而它所依賴的模塊(src/name.js
、src/age.js
)則被放進(jìn)了?modules
?對象中(modules
?用于存放入口文件的依賴模塊,key 值為依賴模塊路徑,value 值為依賴模塊源代碼
)。
require
?函數(shù)是?web 環(huán)境下?加載模塊的方法(?require
?原本是?node環(huán)境?中內(nèi)置的方法,瀏覽器并不認(rèn)識(shí)?require
,所以這里需要手動(dòng)實(shí)現(xiàn)一下),它接受模塊的路徑為參數(shù),返回模塊導(dǎo)出的內(nèi)容。
要想弄清楚 Webpack 原理,那么核心問題就變成了:如何將左邊的源代碼轉(zhuǎn)換成?dist/main.js?文件?
核心思想:
第一步:首先,根據(jù)配置信息(
webpack.config.js
)找到入口文件(src/index.js
)第二步:找到入口文件所依賴的模塊,并收集關(guān)鍵信息:比如
路徑、源代碼、它所依賴的模塊
等:
第三步:根據(jù)上一步得到的信息,生成最終輸出到硬盤中的文件(dist):包括 modules 對象、require 模版代碼、入口執(zhí)行文件等
在這過程中,由于瀏覽器并不認(rèn)識(shí)除?html、js、css
?以外的文件格式,所以我們還需要對源文件進(jìn)行轉(zhuǎn)換 —— **Loader 系統(tǒng)
**。
Loader 系統(tǒng)?本質(zhì)上就是接收資源文件,并對其進(jìn)行轉(zhuǎn)換,最終輸出轉(zhuǎn)換后的文件:

除此之外,打包過程中也有一些特定的時(shí)機(jī)需要處理,比如:
在打包前需要校驗(yàn)用戶傳過來的參數(shù),判斷格式是否符合要求
在打包過程中,需要知道哪些模塊可以忽略編譯,直接引用 cdn 鏈接
在編譯完成后,需要將輸出的內(nèi)容插入到 html 文件中
在輸出到硬盤前,需要先清空 dist 文件夾
......
這個(gè)時(shí)候需要一個(gè)可插拔的設(shè)計(jì),方便給社區(qū)提供可擴(kuò)展的接口 —— **Plugin 系統(tǒng)
**。
Plugin 系統(tǒng)?本質(zhì)上就是一種事件流的機(jī)制,到了固定的時(shí)間節(jié)點(diǎn)就廣播特定的事件,用戶可以在事件內(nèi)執(zhí)行特定的邏輯,類似于生命周期:

這些設(shè)計(jì)也都是根據(jù)使用場景來的,只有理清需求后我們才能更好的理解它的設(shè)計(jì)思想。
四、架構(gòu)設(shè)計(jì)
在理清楚核心思想后,剩下的就是對其進(jìn)行一步步拆解。
上面提到,我們需要建立一套事件流的機(jī)制來管控整個(gè)打包過程,大致可以分為三個(gè)階段:
打包開始前的準(zhǔn)備工作
打包過程中(也就是編譯階段)
打包結(jié)束后(包含打包成功和打包失敗)
這其中又以編譯階段最為復(fù)雜,另外還考慮到一個(gè)場景:watch mode[1](當(dāng)文件變化時(shí),將重新進(jìn)行編譯),因此這里最好將編譯階段(也就是下文中的compilation
)單獨(dú)解耦出來。
在?Webpack?源碼中,compiler
?就像是一個(gè)大管家,它就代表上面說的三個(gè)階段,在它上面掛載著各種生命周期函數(shù),而?compilation
?就像專管伙食的廚師,專門負(fù)責(zé)編譯相關(guān)的工作,也就是打包過程中
這個(gè)階段。畫個(gè)圖幫助大家理解:

大致架構(gòu)定下后,那現(xiàn)在應(yīng)該如何實(shí)現(xiàn)這套事件流呢?
這時(shí)候就需要借助?Tapable?了!它是一個(gè)類似于 Node.js 中的?EventEmitter?的庫,但更專注于自定義事件的觸發(fā)和處理。通過 Tapable 我們可以注冊自定義事件,然后在適當(dāng)?shù)臅r(shí)機(jī)去執(zhí)行自定義事件。
類比到?Vue
?和?React
?框架中的生命周期函數(shù),它們就是到了固定的時(shí)間節(jié)點(diǎn)就執(zhí)行對應(yīng)的生命周期,tapable
?做的事情就和這個(gè)差不多,我們可以通過它先注冊一系列的生命周期函數(shù),然后在合適的時(shí)間點(diǎn)執(zhí)行。
example ??:
運(yùn)行上面這段代碼,得到結(jié)果:
在 Webpack 中,就是通過?tapable
?在?comiler
?和?compilation
?上像這樣掛載著一系列生命周期 Hook
,它就像是一座橋梁,貫穿著整個(gè)構(gòu)建過程:
五、具體實(shí)現(xiàn)
整個(gè)實(shí)現(xiàn)過程大致分為以下步驟:
(1)搭建結(jié)構(gòu),讀取配置參數(shù)
(2)用配置參數(shù)對象初始化?
Compiler
?對象(3)掛載配置文件中的插件
(4)執(zhí)行?
Compiler
?對象的?run
?方法開始執(zhí)行編譯(5)根據(jù)配置文件中的?
entry
?配置項(xiàng)找到所有的入口(6)從入口文件出發(fā),調(diào)用配置的?
loader
?規(guī)則,對各模塊進(jìn)行編譯(7)找出此模塊所依賴的模塊,再對依賴模塊進(jìn)行編譯
(8)等所有模塊都編譯完成后,根據(jù)模塊之間的依賴關(guān)系,組裝代碼塊?
chunk
(9)把各個(gè)代碼塊?
chunk
?轉(zhuǎn)換成一個(gè)一個(gè)文件加入到輸出列表(10)確定好輸出內(nèi)容之后,根據(jù)配置的輸出路徑和文件名,將文件內(nèi)容寫入到文件系統(tǒng)
5.1、搭建結(jié)構(gòu),讀取配置參數(shù)
根據(jù)?Webpack?的用法可以看出, Webpack 本質(zhì)上是一個(gè)函數(shù),它接受一個(gè)配置信息作為參數(shù),執(zhí)行后返回一個(gè)?compiler?對象,調(diào)用?compiler
?對象中的?run?方法就會(huì)啟動(dòng)編譯。run
?方法接受一個(gè)回調(diào),可以用來查看編譯過程中的錯(cuò)誤信息或編譯信息。
修改?debugger.js?中 webpack 的引用:
搭建結(jié)構(gòu):
運(yùn)行流程圖:

5.4、執(zhí)行Compiler
對象的run
方法開始執(zhí)行編譯
重點(diǎn)來了!
在正式開始編譯前,我們需要先調(diào)用?Compiler
?中的?run
?鉤子,表示開始啟動(dòng)編譯了;在編譯結(jié)束后,需要調(diào)用?done
?鉤子,表示編譯完成。
上面架構(gòu)設(shè)計(jì)中提到過,編譯這個(gè)階段需要單獨(dú)解耦出來,通過?Compilation
?來完成,定義Compilation
?大致結(jié)構(gòu):
運(yùn)行流程圖(點(diǎn)擊可放大):

5.5、根據(jù)配置文件中的entry
配置項(xiàng)找到所有的入口
接下來就正式開始編譯了,邏輯均在?Compilation
?中。
在編譯前我們首先需要知道入口文件,而?入口的配置方式[2]?有多種,可以配置成字符串,也可以配置成一個(gè)對象,這一步驟就是為了統(tǒng)一配置信息的格式,然后找出所有的入口(考慮多入口打包的場景)。
運(yùn)行流程圖(點(diǎn)擊可放大):

5.6、從入口文件出發(fā),調(diào)用配置的loader
規(guī)則,對各模塊進(jìn)行編譯
Loader 本質(zhì)上就是一個(gè)函數(shù),接收資源文件或者上一個(gè) Loader 產(chǎn)生的結(jié)果作為入?yún)ⅲ罱K輸出轉(zhuǎn)換后的結(jié)果。
寫兩個(gè)自定義 Loader 配置到?webpack.config.js?中:
webpack.config.js
這一步驟將從入口文件出發(fā),然后查找出對應(yīng)的 Loader 對源代碼進(jìn)行翻譯和替換。
主要有三個(gè)要點(diǎn):
(6.1)把入口文件的絕對路徑添加到依賴數(shù)組(
this.fileDependencies
)中,記錄此次編譯依賴的模塊(6.2)得到入口模塊的的?
module
?對象 (里面放著該模塊的路徑、依賴模塊、源代碼等)(6.2.1)讀取模塊內(nèi)容,獲取源代碼
(6.2.2)創(chuàng)建模塊對象
(6.2.3)找到對應(yīng)的?
Loader
?對源代碼進(jìn)行翻譯和替換(6.3)將生成的入口文件?
module
?對象 push 進(jìn)?this.modules
?中
6.1:把入口文件的絕對路徑添加到依賴數(shù)組中,記錄此次編譯依賴的模塊
這里因?yàn)橐@取入口文件的絕對路徑,考慮到操作系統(tǒng)的兼容性問題,需要將路徑的?\
?都替換成?/
:
6.2.1:讀取模塊內(nèi)容,獲取源代碼
6.2.2:創(chuàng)建模塊對象
6.2.3:找到對應(yīng)的?Loader
?對源代碼進(jìn)行翻譯和替換
6.3:將生成的入口文件?module
?對象 push 進(jìn)?this.modules
?中
運(yùn)行流程圖(點(diǎn)擊可放大):

5.7、找出此模塊所依賴的模塊,再對依賴模塊進(jìn)行編譯
該步驟是整體流程中最為復(fù)雜的,一遍看不懂沒關(guān)系,可以先理解思路。
該步驟經(jīng)過細(xì)化可以將其拆分成十個(gè)小步驟:
(7.1):先把源代碼編譯成?AST[3]
(7.2):在?
AST
?中查找?require
?語句,找出依賴的模塊名稱和絕對路徑(7.3):將依賴模塊的絕對路徑 push 到?
this.fileDependencies
?中(7.4):生成依賴模塊的
模塊 id
(7.5):修改語法結(jié)構(gòu),把依賴的模塊改為依賴
模塊 id
(7.6):將依賴模塊的信息 push 到該模塊的?
dependencies
?屬性中(7.7):生成新代碼,并把轉(zhuǎn)譯后的源代碼放到?
module._source
?屬性上(7.8):對依賴模塊進(jìn)行編譯(對?
module 對象
中的?dependencies
?進(jìn)行遞歸執(zhí)行?buildModule
?)(7.9):對依賴模塊編譯完成后得到依賴模塊的?
module 對象
,push 到?this.modules
?中(7.10):等依賴模塊全部編譯完成后,返回入口模塊的?
module
?對象
運(yùn)行流程圖(點(diǎn)擊可放大):

5.8、等所有模塊都編譯完成后,根據(jù)模塊之間的依賴關(guān)系,組裝代碼塊?chunk
現(xiàn)在,我們已經(jīng)知道了入口模塊和它所依賴模塊的所有信息,可以去生成對應(yīng)的代碼塊了。
一般來說,每個(gè)入口文件會(huì)對應(yīng)一個(gè)代碼塊chunk
,每個(gè)代碼塊chunk
里面會(huì)放著本入口模塊和它依賴的模塊,這里暫時(shí)不考慮代碼分割。
運(yùn)行流程圖(點(diǎn)擊可放大):

5.9、把各個(gè)代碼塊?chunk
?轉(zhuǎn)換成一個(gè)一個(gè)文件加入到輸出列表
這一步需要結(jié)合配置文件中的output.filename
去生成輸出文件的文件名稱,同時(shí)還需要生成運(yùn)行時(shí)代碼:
到了這里,Compilation
?的邏輯就走完了。
運(yùn)行流程圖(點(diǎn)擊可放大):

5.10、確定好輸出內(nèi)容之后,根據(jù)配置的輸出路徑和文件名,將文件內(nèi)容寫入到文件系統(tǒng)
該步驟就很簡單了,直接按照 Compilation 中的 this.status 對象將文件內(nèi)容寫入到文件系統(tǒng)(這里就是硬盤)。
運(yùn)行流程圖(點(diǎn)擊可放大):

完整流程圖
以上就是整個(gè) Webpack 的運(yùn)行流程圖,還是描述的比較清晰的,跟著一步步走看懂肯定沒問題!

執(zhí)行?node ./debugger.js
,通過我們手寫的 Webpack 進(jìn)行打包,得到輸出文件?dist/main.js:

六、實(shí)現(xiàn) watch 模式
看完上面的實(shí)現(xiàn),有些小伙伴可能有疑問了:Compilation
?中的?this.fileDependencies
(本次打包涉及到的文件)是用來做什么的?為什么沒有地方用到該屬性?
這里其實(shí)是為了實(shí)現(xiàn)?Webpack 的 watch 模式[4]:當(dāng)文件發(fā)生變更時(shí)將重新編譯。
思路:對?this.fileDependencies
?里面的文件進(jìn)行監(jiān)聽,當(dāng)文件發(fā)生變化時(shí),重新執(zhí)行?compile
?函數(shù)。
相信看到這里,你一定也理解了 compile 和 Compilation 的設(shè)計(jì),都是為了解耦和復(fù)用呀。
七、總結(jié)
本文從 Webpack 的基本使用和構(gòu)建產(chǎn)物出發(fā),從思想和架構(gòu)兩方面深度剖析了 Webpack 的設(shè)計(jì)理念。最后在代碼實(shí)現(xiàn)階段,通過百來行代碼手寫了 Webpack 的整體流程,盡管它只能對文件進(jìn)行打包,還缺少很多功能,但麻雀雖小,卻也五臟俱全。