作為一個敲碼5分鐘,除錯兩小時的bug大叔,每天和console.log打的交道自然不少,人到中年,越來越懶,於是想把console.log('bug: ', bug)變成log.bug來讓我的懶癌病發得更加徹底。於是硬著頭皮看了下webpack外掛的寫法,在此記錄一下webpack系統的學習筆記。
跟著webpack原始碼摸石過河
去吧!皮卡丘!!!
- webpack初始化
// webpack.config.js
const webpack = require('webpack');
const WebpackPlugin = require('webpack-plugin');
const options = {
entry: 'index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'index.bundle.js'
},
module: {
rules: []
},
plugins: [
new WebpackPlugin()
]
// ...
};
webpack(options);
複製程式碼
// webpack.js
// 引入Compiler類
const Compiler = require("./Compiler");
// webpack入口函式
const webpack = (options, callback) => {
// 建立一個Compiler例項
compiler = new Compiler(options.context);
// 例項儲存webpack的配置物件
compiler.options = options
// 依次呼叫webpack外掛的apply方法,並傳入compiler引用
for (const plugin of options.plugins) {
plugin.apply(compiler);
}
//執行例項的run方法
compiler.run(callback)
//返回例項
return compiler
}
module.exports = webpack
複製程式碼
- 最開始,無論我們在控制檯輸入webpack指令還是使用Node.js的API,都是呼叫了webpack函式(原始碼),並傳入了webpack的配置選項,建立了一個
compiler
例項。 compiler
是什麼?—— 明顯發現compiler儲存了完整的webpack的配置引數options。所以官方說:
compiler 物件代表了完整的 webpack 環境配置。這個物件在啟動 webpack 時被一次性建立,並配置好所有可操作的設定,包括 options,loader 和 plugin。可以使用 compiler 來訪問 webpack 的主環境。
- 所有
webpack-plugin
也在這裡通過提供一個叫apply
的方法給webpack呼叫,以完成初始化的工作,並且接收到剛建立的compiler
的引用。
- compiler、compilation與tapable
// Compiler.js
const {
Tapable,
SyncHook,
SyncBailHook,
AsyncParallelHook,
AsyncSeriesHook
} = require("tapable");
const Compilation = require("./Compilation");
class Compiler extends Tapable {
hooks = [
hook1: new SyncHook(),
hook2: new AsyncSeriesHook(),
hook3: new AsyncParallelHook()
]
run () {
// ...
// 觸發鉤子回撥執行
this.hooks[hook1].call()
this.hooks[hook2].callAsync(() => {
this.hooks[hook4].call()
})
// 進入編譯流程
this.compile()
// ...
this.hooks[hook3].promise(() => {})
}
compile () {
// 建立一個Compilation例項
const compilation = new Compilation(this)
}
}
複製程式碼
-
研究下Compiler.js這個檔案,它引入了一個
tapable
類(原始碼)的庫,這個庫提供了一些Hook
類,內部實現了類似的事件訂閱/釋出系統
。 -
哪裡用到
Hook
類?—— 在Compiler
類(Compiler.js原始碼) 裡擁有很多有意思的hook,這些hook代表了整個編譯過程的各個關鍵事件節點,它們都是繼承於Hook
類 ,所以都支援監聽/訂閱
,只要我們的外掛提前做好事件訂閱,那麼編譯流程進行到該事件點時,就會執行我們提供的事件回撥
,做我們想做的事情了。 -
如何進行訂閱呢?——在上文中每個webpack-plugin中都有一個apply方法。其實註冊的程式碼就藏在裡面,通過以下類似的程式碼實現。任何的webpack外掛都可以訂閱hook1,因此hook1維護了一個taps陣列,儲存著所有的callback。
compiler.hooks.hook1.tap(name, callback) // 註冊/訂閱
compiler.hooks.hook1.call() // 觸發/釋出
-
準備工作做好了之後,當
run()
方法開始被呼叫時,編譯就正式開始了。在該方法裡,執行call/callAsync/promise
這些事件時(他們由webpack內部包括一些官方使用的webpack外掛進行觸發的管理,無需開發者操心),相應的hook就會把自己的taps裡的函式均執行一遍。大概的邏輯如下所示。 -
其中,hooks的執行是按照編譯的流程順序來的,hooks之間有彼此依賴的關係。
-
compilation
例項也在這裡建立了,它代表了一次資源版本構建。每當檢測到檔案變化,就會建立一個新的 compilation,從而生成一組新的編譯資源。一個 compilation 物件表現了當前的模組資源、編譯生成資源、變化的檔案、以及被跟蹤依賴的狀態資訊。而且compilation也和compiler類似,擁有很多hooks(Compilation.js原始碼),因此同樣提供了很多事件節點給予我們訂閱使用。
- webpack外掛體系的使用套路
class MyPlugin {
apply(compiler) {
// 設定回撥來訪問 compilation 物件:
compiler.hooks.compilation.tap('myPlugin', (compilation) => {
// 現在,設定回撥來訪問 compilation 中的任務點:
compilation.hooks.optimize.tap('myPlugin', () => {
console.log('Hello compilation!');
});
});
}
}
module.exports = MyPlugin;
複製程式碼
- 訂閱
compiler
和complation
的事件節點都在webpack-plugin中的apply
方法裡書寫,具體的演示如上。當然你想拿到編譯過程中的什麼資源,首先得要找出能提供該資源引用的對應的compiler事件節點進行訂閱(上帝先創造了亞當,再有夏娃)。每個compiler時間節點能提供什麼引數,在hook的例項化時已經做了說明(如下),更多可檢視原始碼
this.hooks = {
// 拿到compilation和params
compilation: new SyncHook(["compilation", "params"]),
// 拿到stats
done: new AsyncSeriesHook(["stats"]),
// 拿到compiler
beforeRun: new AsyncSeriesHook(["compiler"])
}
複製程式碼
梳理webpack流程
經過以上的初步探索,寫webpack外掛需要了解的幾個知識點應該有了大概的掌握:
- 外掛提供
apply
方法供webpack
呼叫進行初始化 - 使用
tap
註冊方式鉤入compiler
和compilation
的編譯流程 - 使用
webpack
提供的api
進行資源的個性化處理。
寫外掛的套路已經知道了,現在還剩如何找出合適的鉤子,修改資源這件事。在webpack系統裡,鉤子即流程,是編譯構建工作的生命週期
。當然,想要了解所有 tapable
例項物件的鉤子的具體作用,需要探索webpack所有的內部外掛如何使用這些鉤子,做了什麼工作來進行總結,想想就複雜,所以只能抽取重要流程做思路概括,借用淘寶的一張經典圖示。![webpack_flow.jpg](file:///Users/Ando/Documents/webpack-plugin/webpack_flow.jpg)
整個編譯流程大概分成三個階段,現在重新整理一下:
準備階段
,webpack的初始化
- webpack依次呼叫開發者自定義的外掛的
apply
方法,讓外掛們做好事件註冊。 WebpackOptionsApply
接收組合了命令列
和webpack.config.js
的配置引數,負責webpack內部基礎流程使用的外掛和compiler上下文環境的初始化工作。(*Plugin
均為webpack內部使用的外掛)// webpack.js // 開發者自定義的外掛初始化 if (options.plugins && Array.isArray(options.plugins)) { for (const plugin of options.plugins) { if (typeof plugin === "function") { plugin.call(compiler, compiler) } else { plugin.apply(compiler) } } } // ... // webpack內部使用的外掛初始化 compiler.options = new WebpackOptionsApply().process(options, compiler) // WebpackOptionsApply.js class WebpackOptionsApply extends OptionsApply { process (options, compiler) { // ... new WebAssemblyModulesPlugin({ mangleImports: options.optimization.mangleWasmImports }).apply(compiler); new EntryOptionPlugin().apply(compiler); compiler.hooks.entryOption.call(options.context, options.entry); new CompatibilityPlugin().apply(compiler); new HarmonyModulesPlugin(options.module).apply(compiler); new AMDPlugin(options.module, options.amd || {}).apply(compiler); // ... } } 複製程式碼
- 執行
run / watch
(一次打包/監聽打包模式)觸發編譯
階段
編譯階段
,生成module
,chunk
資源。run
→compile
編譯 → 建立compilation
物件。compilation
的建立是一次編譯的起步,即將對所有模組載入(load)
、封存(seal)
、優化(optimiz)
、分塊(chunk)
、雜湊(hash)
和重新建立(restore)
。module.exports = class Compiler extends Tapable { run () { // 宣告編譯結束回撥 function onCompiled () {} // 觸發run鉤子 this.hooks.run.callAsync(this, err => { this.compile(onCompiled) }) } compile(callback) { // ... // 編譯開始前,觸發beforeCompile鉤子 this.hooks.beforeCompile.callAsync(params, err => { // 編譯開始,觸發compile鉤子 this.hooks.compile.call(params); // 建立compilation例項 const compilation = this.newCompilation(params); // 觸發make鉤子 this.hooks.make.callAsync(compilation, err => { // 模組解析完畢,執行compilation的finish方法 compilation.finish(); // 資源封存,執行seal方法 compilation.seal(err => { // 編譯結束,執行afterCompile鉤子 this.hooks.afterCompile.callAsync(compilation, err => { // ... }); }); }); }); } } 複製程式碼
載入模組
:make
鉤子觸發 →DllEntryPlugin
內部外掛呼叫compilation.addEntry
→compilation
維護了一些資源生成工廠方法compilation.dependencyFactories
,負責把入口檔案及其(迴圈)依賴轉換成module
(module
的解析過程會應用匹配的loader
)。每個module
的解析過程提供buildModule / succeedModule
等鉤子, 所有module
解析完成後觸發finishModules
鉤子。封存
:seal
方法包含了優化/分塊/雜湊
, 編譯停止接收新模組,開始生成chunks。此階段依賴了一些webpack內部外掛對module進行優化,為本次構建生成的chunk加入hash等。createChunkAssets()會根據chunk型別使用不同的模板進行渲染。此階段執行完畢後就代表一次編譯完成,觸發afterCompile
鉤子優化
:BannerPlugin
compilation.hooks.optimizeChunkAssets.tapAsync('MyPlugin', (chunks, callback) => { chunks.forEach(chunk => { chunk.files.forEach(file => { compilation.assets[file] = new ConcatSource( '\/**Sweet Banner**\/', '\n', compilation.assets[file] ); }); }); callback(); }); 複製程式碼
分塊
:用來分割chunk的SplitChunksPlugin
外掛監聽了optimizeChunksAdvanced
鉤子雜湊
:createHash
- 檔案生成階段
- 編譯完成後,觸發
emit
,遍歷compilation.assets
生成所有檔案。
寫一個增強console.log除錯體驗的webpack外掛 simple-log-webpack-plugin
一張效果圖先上為敬。(對照圖片)只需寫
log.a
,通過自己的webpack外掛自動補全欄位標識a欄位:
,加入檔案路徑
,輕鬆支援列印顏色
效果,相同檔案的日誌資訊可摺疊
,給你一個簡潔方便的除錯環境。
const ColorHash = require('color-hash')
const colorHash = new ColorHash()
const Dependency = require('webpack/lib/Dependency');
class LogDependency extends Dependency {
constructor(module) {
super();
this.module = module;
}
}
LogDependency.Template = class {
apply(dep, source) {
const before = `;console.group('${source._source._name}');`
const after = `;console.groupEnd();`
const _size = source.size()
source.insert(0, before)
source.replace(_size, _size, after)
}
};
module.exports = class LogPlugin {
constructor (opts) {
this.options = {
expression: /\blog\.(\w+)\b/ig,
...opts
}
this.plugin = { name: 'LogPlugin' }
}
doLog (module) {
if (!module._source) return
let _val = module._source.source(),
_name = module.resource;
const filedColor = colorHash.hex(module._buildHash)
// 判斷是否需要加入
if (this.options.expression.test(_val)) {
module.addDependency(new LogDependency(module));
}
_val = _val.replace(
this.options.expression,
`console.log('%c$1欄位:%c%o, %c%s', 'color: ${filedColor}', 'color: red', $1, 'color: pink', '${_name}')`
)
return _val
}
apply (compiler) {
compiler.hooks.compilation.tap(this.plugin, (compilation) => {
// 註冊自定義依賴模板
compilation.dependencyTemplates.set(
LogDependency,
new LogDependency.Template()
);
// modlue解析完畢鉤子
compilation.hooks.succeedModule.tap(this.plugin, module => {
// 修改模組的程式碼
module._source._value = this.doLog(module)
})
})
}
}
複製程式碼