webpack的熟練使用已成爲當代前端工程師必備的生存技能。毋庸置疑,webpack已成爲前端構建工具的佼佼者,網絡上關於如何使用webpack的技術文檔層出不窮。但鮮有能將webpack的構建流程講清楚的。本文嘗試從解讀源碼以及斷點調試的方式,來探究 webpack 是如何一步步的構建資源的。css
截至本文發表前,webpack的最新版本爲webpack 5.0.0-beta.1
,即本文的源碼來自於最新的webpack v5
。前端
特別說明,本文所列源碼均通過精簡加工,若是要看具體代碼你能夠根據我標識的源碼文件名訪問webpack官方庫查看。本文精簡部分:node
const xxx = require('XXX')
;我一向認爲學習源碼並非硬着頭皮去一行行的閱讀代碼,對於一個成熟的開源項目,一定是存在不少錯綜複雜的分支走向。試着一步步的調試代碼來跟蹤程序運行路徑,是快速瞭解一個項目基本架構的最快方式。webpack
VS Code編輯器中完善的Debugger功能是調試Node程序最好利器。git
git clone https://github.com/webpack/webpack.git
複製代碼
npm install
cd webpack/
code .
複製代碼
debug
文件夾,用於存放調試代碼,debug
文件夾結構以下:debug-|
|--dist // 打包後輸出文件
|--src
|--index.js // 源代碼入口文件
|--package.json // debug時須要安裝一些loader和plugin
|--start.js // debug啓動文件
|--webpack.config.js // webpack配置文件
複製代碼
詳細debug代碼以下:github
//***** debug/src/index.js *****
import is from 'object.is' // 這裏引入一個小而美的第三方庫,以此觀察webpack如何處理第三方包
console.log('很高興認識你,webpack')
console.log(is(1,1))
//***** debug/start.js *****
const webpack = require('../lib/index.js') // 直接使用源碼中的webpack函數
const config = require('./webpack.config')
const compiler = webpack(config)
compiler.run((err, stats)=>{
if(err){
console.error(err)
}else{
console.log(stats)
}
})
//***** debug/webpack.config.js *****
const path = require('path')
module.exports = {
context: __dirname,
mode: 'development',
devtool: 'source-map',
entry: './src/index.js',
output: {
path: path.join(__dirname, './dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
exclude: /node_modules/,
}
]
}
}
複製代碼
Debug
欄添加調試 配置:{
"configurations": [
{
"type": "node",
"request": "launch",
"name": "啓動webpack調試程序",
"program": "${workspaceFolder}/debug/start.js"
}
]
}
複製代碼
配置完成後,試着點擊一下 ► (啓動) 看看調試程序是否正常運行(若是成功,在debug/dist
中會打包出一個main.js
文件)。web
若是你有時間,我但願你能親手完成一次webpack調試流程,我相信你會有收穫的。探索欲是人類的天性。shell
接下來,經過斷點調試,來一步步剖析webpack是如何工做的吧。npm
webpack有兩種啓動方式:json
webpack-cli
腳手架來啓動,便可以在Terminal
終端直接運行;webpack ./debug/index.js --config ./debug/webpack.config.js
複製代碼
這種方式是最爲經常使用也是最快捷的方式,開箱即用。
require('webpack')
引入包的方式執行;其實第一種方式最終仍是會用require
的方式來啓動webpack,用興趣的能夠查看./bin/webpack.js
文件。
一切從const compiler = webpack(config)
開始。
webpack函數源碼(./lib/webpack.js
):
const webpack = (options, callback) => {
let compiler = createCompiler(options)
// 若是傳入callback函數,則自啓動
if(callback){
compiler.run((err, states) => {
compiler.close((err2)=>{
callbacl(err || err2, states)
})
})
}
return compiler
}
複製代碼
webpack函數執行後返回compiler
對象,在webpack中存在兩個很是重要的核心對象,分別爲compiler
和compilation
,它們在整個編譯過程當中被普遍使用。
./lib/Compiler.js
):webpack的主要引擎,在compiler對象記錄了完整的webpack環境信息,在webpack從啓動到結束,compiler
只會生成一次。你能夠在compiler
對象上讀取到webpack config
信息,outputPath
等;./lib/Compilation.js
):表明了一次單一的版本構建和生成資源。compilation
編譯做業能夠屢次執行,好比webpack工做在watch
模式下,每次監測到源文件發生變化時,都會從新實例化一個compilation
對象。一個compilation
對象表現了當前的模塊資源、編譯生成資源、變化的文件、以及被跟蹤依賴的狀態信息。二者的區別?
compiler表明的是不變的webpack環境; compilation表明的是一次編譯做業,每一次的編譯均可能不一樣;舉個栗子🌰:
compiler就像一條手機生產流水線,通上電後它就能夠開始工做,等待生產手機的指令; compliation就像是生產一部手機,生產的過程基本一致,但生產的手機多是小米手機也多是魅族手機。物料不一樣,產出也不一樣。
Compiler
類在函數createCompiler
中實例化(./lib/index.js
):
const createCompiler = options => {
const compiler = new Compiler(options.context)
// 註冊全部的自定義插件
if(Array.isArray(options.plugin)){
for(const plugin of options.plugins){
if(typeof plugin === 'function'){
plugin.call(compiler, compiler)
}else{
plugin.apply(compiler)
}
}
}
compiler.hooks.environment.call()
compiler.hooks.afterEnvironment.call()
compiler.options = new WebpackOptionsApply().process(options, compiler) // process中註冊全部webpack內置的插件
return compiler
}
複製代碼
Compiler
類實例化後,若是webpack函數接收了回調callback
,則直接執行compiler.run()
方法,那麼webpack自動開啓編譯之旅。若是未指定callback
回調,須要用戶本身調用run
方法來啓動編譯。
從上面源碼中,能夠得出一些信息:
compiler由Compiler
實例化,裏面的屬性和方法後面一節會提到,其中最重要的是compiler.run()
方法;
遍歷webpack config
中的plugins數組,這裏我加粗了plugins數組
,因此配置plugins時不要配成對象了。(事實上,在webpack函數中會對options作object schema
的校驗)。
plugin
:若是 plugin 是函數,直接調用它;若是 plugin 是其餘類型(主要是object類型),執行plugin對象的apply方法。apply函數簽名:(compiler) => {}
。
webpack很是嚴格的要求咱們plugins數組元素必須是函數,或者一個有apply字段的對象且apply是函數,緣由就在於此。
{ plugins: [ new HtmlWebpackPlugin() ] } 複製代碼
調用鉤子:compiler.hooks.environment.call()
以及 compiler.hooks.afterEnvironment.call()
是源碼閱讀至此咱們最早遇到的鉤子調用,在以後的閱讀中,你會遇到更多的鉤子註冊與調用。要理解webpack鉤子的應用,須要先了解Tapable
,這是編寫插件的基礎。
關於Tapable,我會」另案處理「它的。
process(options)
:在 webpack config中,除了plugins
還有其餘不少的字段呢,那麼process(options)
的做用就是一個個的處理這些字段。
至此,咱們瞭解了webpack在初始化階段作了哪些準備工做。當點燃導火索compiler.run()
時,纔是webpack真正強大的時候。」兵馬未動,糧草先行「,在此以前,須要先看看new WebpackOptionsApply().process(options, compiler)
作了哪些準備工做,它爲後面編譯階段提供了重要的後勤保衛。
WebpackOptionsApply
類的工做就是對webpack options
進行初始化。 打開源碼文件lib/WebpackOptionsApply.js
,你會發現前五十行都是各類webpack內置的Plugin
的引入,那麼能夠猜測process
方法應該是各類各樣的new SomePlugin().apply()
的操做,事實就是如此。
精簡源碼(lib/WebpackOptionsApply.js):
class WebpackOptionsApply extends OptionsApply {
constructor() {
super();
}
process(options, compiler){
// 當傳入的配置信息知足要求,處理與配置項相關的邏輯
if(options.target) {
new OnePlugin().apply(compiler)
}
if(options.devtool) {
new AnotherPlugin().apply(compiler)
}
if ...
new JavascriptModulesPlugin().apply(compiler);
new JsonModulesPlugin().apply(compiler);
new ...
compiler.hooks.afterResolvers.call(compiler);
}
}
複製代碼
源碼中...
省略號省略了不少類似的操做,process
函數很長,有接近500行左右的代碼,主要作了兩件事:
new
不少的Plugin
,而且apply
它們。
在上一小節中,咱們知道webpack插件其實就是一個提供apply方法的類,它在合適的時候會被webpack實例化並執行apply方法。而apply方法接收了
compiler
對象,方便在hooks上監聽消息。 同時在process
函數中實例化的各個Plugin
都是webpack本身維護的,所以你會發現webpack項目根目錄下有不少的以Plugin
結尾的文件。而用戶自定義的插件在以前就已經註冊完成了。 不一樣插件有本身不一樣的使命,它們的職責是鉤住compiler.hooks
上的一個消息,一旦某個消息被觸發,註冊在消息上的回調根據hook類型依次調用。所謂「鉤住」的三個方式:tap
tapAsync
tapPromise
,你須要知道Tapable
的工做原理哦。
根據options.xxx
的配置項,作初始化工做,而大多數初始化工做仍是在幹上面👆的事情
這一小結總結一下:process
函數執行完,webpack將全部它關心的hook消息都註冊完成,等待後續編譯過程當中挨個觸發。
執行process
方法裝填好彈藥,等待大戰即發。
先貼上源碼吧(./lib/Compiler.js
):
class Compiler {
constructor(context){
// 全部鉤子都是由`Tapable`提供的,不一樣鉤子類型在觸發時,調用時序也不一樣
this.hooks = {
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),
done: new AsyncSeriesHook(["stats"]),
// ...
}
}
// ...
run(callback){
const onCompiled = (err, compilation) => {
if(err) return
const stats = new Stats(compilation);
this.hooks.done.callAsync(stats, err => {
if(err) return
callback(err, stats)
this.hooks.afterDone.call(stats)
})
}
this.hooks.beforeRun.callAsync(this, err => {
if(err) return
this.hooks.run.callAsync(this, err => {
if(err) return
this.compile(onCompiled)
})
})
}
}
複製代碼
通讀一遍run
函數過程,你會發現它鉤住了編譯過程的一些階段,並在相應階段去調用已經提早註冊好的鉤子函數(this.hooks.xxxx.call(this)
),效果與React中生命週期函數是同樣的。在run
函數中出現的鉤子有:beforeRun --> run --> done --> afterDone
。第三方插件能夠鉤住不一樣的生命週期,接收compiler
對象,處理不一樣邏輯。
run
函數鉤住了webpack編譯的前期和後期的階段,那麼中期最爲關鍵的代碼編譯過程就交給了this.compile()
來完成了。在this.comille()
中,另外一個主角compilation粉墨登場了。
compiler.compile
函數是模塊編譯的主戰場,話很少說,先貼上精簡後僞代碼:
compile(callback){
const params = this.newCompilationParams() // 初始化模塊工廠對象
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params)
// compilation記錄本次編譯做業的環境信息
const compilation = new Compilation(this)
this.hooks.make.callAsync(compilation, err => {
compilation.finish(err => {
compilation.seal(err=>{
this.hooks.afterCompile.callAsync(compilation, err => {
return callback(null, compilation)
})
})
})
})
})
}
複製代碼
compile
函數和run
同樣,觸發了一系列的鉤子函數,在compile
函數中出現的鉤子有:beforeCompile --> compile --> make --> afterCompile
。
其中make
就是咱們關心的編譯過程。但在這裏它僅是一個鉤子觸發,顯然真正的編譯執行是註冊在這個鉤子的回調上面。
webpack由於有Tapable
的加持,代碼編寫很是靈活,node中流行的callback回調機制(說的就是回調地獄),webpack使用的爐火純青,若是用斷點調試,可能不太容易捕捉到。這裏我使用搜索關鍵詞的方法反向查找make
鉤子是在哪裏註冊的。
經過搜索關鍵詞hooks.make.tapAsync
咱們發如今lib/EntryPlugin.js
中找到了它的身影。
依靠搜索關鍵詞,會列出較多幹擾項,聰明的你就須要識別出哪一個選項纔是最接近實際狀況的。
此時,咱們要倒查一下這個EntryPlugin
是在何時被調用的,繼續關鍵詞new EntryPlugin
搜索,在lib/EntryOptionPlugin.js
中找到了它,並且其中你發現了熟悉的「東西」:
if(typeof entry === "string" || Array.isArray(entry)){
applyEntryPlugins(entry, "main")
}else if (typeof entry === "object") {
for (const name of Object.keys(entry)) {
applyEntryPlugins(entry[name], name);
}
} else if (typeof entry === "function") {
new DynamicEntryPlugin(context, entry).apply(compiler);
}
複製代碼
還記得在webpack.config.js
中,entry
字段是怎麼配置的嗎?此時你會明白entry
是字符串或數組時,打包出來的資源統一叫main.js
這個名字了。
咱們的回溯尚未結束,繼續搜索關鍵詞new EntryOptionPlugin
,Oops,搜索到的文件就是lib/WebpackOptionsApply.js
。如此一切都明瞭了,make
鉤子在process
函數中就已經註冊好了,就等着你來調用。
回到lib/EntryPlugin.js
看看compiler.hooks.make.tapAsync
都幹了啥。其實就是運行compiliation.addEntry
方法,繼續探索compiliation.addEntry
。
addEntry(context, entry, name, callback) {
this.hooks.addEntry.call(entry, name);
// entryDependencies中的每一項都表明了一個入口,打包輸出就會有多個文件
let entriesArray = this.entryDependencies.get(name)
entriesArray.push(entry)
this.addModuleChain(context, entry, (err, module) => {
this.hooks.succeedEntry.call(entry, name, module);
return callback(null, module);
})
}
複製代碼
addEntry
的做用是將模塊的入口信息傳遞給模塊鏈中,即addModuleChain
,隨後繼續調用compiliation.factorizeModule
,這些調用最後會將entry
的入口信息」翻譯「成一個模塊(嚴格上說,模塊是NormalModule
實例化後的對象)。讀這段源碼的時候,有點難理解,因爲node回調地獄的陷進,我一度覺得entry
的處理應該是同步,後來發現process.nextTick
的使用使得不少回調都是異步調用的。建議在這裏多斷點,多調試,以理解彎彎繞的異步回調。
這裏我列出相關函數的調用順序:this.addEntry --> this.addModuleChain --> this.handleModuleCreation --> this.addModule --> this.buildModule --> this._buildModule --> module.build
(this指代compiliation
)`。
最終會走到NormalModule
對象(./lib/NormalModule.js
)中,執行build
方法。
在normalModule.build
方法中會先調用自身doBuild
方法:
const { runLoaders } = require("loader-runner");
doBuild(options, compilation, resolver, fs, callback){
// runLoaders從包'loader-runner'引入的方法
runLoaders({
resource: this.resource, // 這裏的resource多是js文件,多是css文件,多是img文件
loaders: this.loaders,
}, (err, result) => {
const source = result[0];
const sourceMap = result.length >= 1 ? result[1] : null;
const extraInfo = result.length >= 2 ? result[2] : null;
// ...
})
}
複製代碼
其實doBuild
就是選用合適的loader
去加載resource
,目的是爲了將這份resource
轉換爲JS模塊(緣由是webpack只識別JS模塊)。最後返回加載後的源文件source
,以便接下來繼續處理。
webpack對處理標準的JS模塊很在行,但處理其餘類型文件(css, scss, json, jpg)等就無能爲力了,此時它就須要loader的幫助。loader的做用就是轉換源代碼爲JS模塊,這樣webpack就能夠正確識別了。
loader
的做用就像是Linux中信息流管道,它接收源碼字符串流,加工一下,而後返回加工後的源碼字符串交給下一個loader
繼續處理。loader
的基本範式:(code, sourceMap, meta) => string
通過了doBuild
後,任何的模塊都轉換成標準JS模塊。
能夠試着在js代碼中引入css代碼,觀察一下轉換出的標準JS模塊的數據結構。
![]()
接下來就是編譯標準JS代碼了。在傳入doBuild
的回調函數中這樣處理source
:
const result = this.parser.parse(source)
複製代碼
而這裏的this.parser
其實就是JavascriptParser
的實例對象,最終JavascriptParser
會調用第三方包acorn
提供的parse
方法對JS源代碼進行語法解析。
parse(code, options){
// 調用第三方插件`acorn`解析JS模塊
let ast = acorn.parse(code)
// 省略部分代碼
if (this.hooks.program.call(ast, comments) === undefined) {
this.detectStrictMode(ast.body)
this.prewalkStatements(ast.body)
this.blockPrewalkStatements(ast.body)
// 這裏webpack會遍歷一次ast.body,其中會收集這個模塊的全部依賴項,最後寫入到`module.dependencies`中
this.walkStatements(ast.body)
}
}
複製代碼
有個線上小工具 AST explorer 能夠在線將JS代碼轉換爲語法樹AST,將解析器選擇爲acorn
便可。將調試代碼./debug/src/index.js
使用acron
解析一下語法,獲得以下的數據結構:
可能你會有些疑惑,一般咱們會使用一些相似於babel-loader
等 loader 預處理源文件,那麼webpack 在這裏的parse
具體做用是什麼呢?parse
的最大做用就是收集模塊依賴關係,好比調試代碼中出現的import {is} from 'object-is'
或const xxx = require('XXX')
的模塊引入語句,webpack會記錄下這些依賴項,記錄在module.dependencies
數組中。
至此,從入口文件開始,webpack收集完整了該模塊的信息和依賴項,接下來就是如何進一步打包封裝模塊了。
在執行
compilation.seal
(./lib/Compliation
)以前,你能夠打個斷點,查看此時compilation.modules
的狀況。此時compilation.modules
有三個子模塊,分別爲./src/index.js
node_modules/object.is/index.js
以及node_modules/object.is/is.is
compilation.seal
的步驟比較多,先封閉模塊,生成資源,這些資源保存在compilation.assets
, compilation.chunks
。
你會在多數第三方webpack插件中看到
compilation.assets
和compilation.chunks
的身影。
而後調用compilation.createChunkAssets
方法把全部依賴項經過對應的模板 render 出一個拼接好的字符串:
createChunkAssets(callback){
asyncLib.forEach(
this.chunks,
(chunk, callback) => {
// manifest是數組結構,每一個manifest元素都提供了 `render` 方法,提供後續的源碼字符串生成服務。至於render方法什麼時候初始化的,在`./lib/MainTemplate.js`中
let manifest = this.getRenderManifest()
asyncLib.forEach(
manifest,
(fileManifest, callback) => {
...
source = fileManifest.render()
this.emitAsset(file, source, assetInfo)
},
callback
)
},
callback
)
}
複製代碼
能夠在createChunkAssets
方法體中的this.emitAsset(file, source, assetInfo)
代碼行打上斷點,觀察此時source
中的數據結構。在source._source
字段已經初見打包後源碼雛形:
值得一提的是,createChunkAssets
執行過程當中,會優先讀取cache中是否已經有了相同hash的資源,若是有,則直接返回內容,不然纔會繼續執行模塊生成的邏輯,並存入cache中。
在seal執行後,關於模塊全部信息以及打包後源碼信息都存在內存中,是時候將它們輸出爲文件了。接下來就是一連串的callback回調,最後咱們到達了compiler.emitAssets
方法體中。在compiler.emitAssets
中會先調用this.hooks.emit
生命週期,以後根據webpack config文件的output配置的path屬性,將文件輸出到指定的文件夾。至此,你就能夠在./debug/dist
中查看到調試代碼打包後的文件了。
this.hooks.emit.callAsync(compilation, () => {
outputPath = compilation.getPath(this.outputPath, {})
mkdirp(this.outputFileSystem, outputPath, emitFiles)
})
複製代碼
很是感謝你閱讀到最後,本文篇幅較長,簡單總結一下 webpack 編譯模塊的基本流程:
webpack
函數接收config
配置信息,並初始化compiler
,在此期間會apply
全部 webpack 內置的插件;compiler.run
進入模塊編譯階段;compilation
對象,記錄本次編譯的基本信息;make
階段,即觸發compilation.hooks.make
鉤子,從entry
爲入口: a. 調用合適的loader
對模塊源碼預處理,轉換爲標準的JS模塊; b. 調用第三方插件acorn
對標準JS模塊進行分析,收集模塊依賴項。同時也會繼續遞歸每一個依賴項,收集依賴項的依賴項信息,不斷遞歸下去;最終會獲得一顆依賴樹🌲;compilation.seal
render 模塊,整合各個依賴項,最後輸出一個或多個chunk;如下爲簡單的時序圖:
以上過程並不能徹底歸納webpack的所有流程,隨着webpack.config
配置愈來愈複雜,webpack會衍生更多的流程去應對不一樣的狀況。
webpack複雜嗎?很複雜,Tabable
與Node回調
讓整個流程存在多種多樣的走向,也由於它的插件系統,讓 webpack 高度可配置。 webpack容易嗎?也容易,它只作了一件事,編譯打包JS模塊,並把這件事作到極致完美。
碼字不易,若是:
您的支持與關注,是我持續創做的最大動力!