作者:frank
轉發鏈接:https://mp.weixin.qq.com/s/LI-SkBoPA94Ply6Qes92PA
前言
通過插件我們可以擴展webpack,在合適的時機通過Webpack提供的 API 改變輸出結果,使webpack可以執行更廣泛的任務,擁有更強的構建能力。 本文將嘗試探索 webpack 插件的工作流程,進而去揭秘它的工作原理。同時需要你對webpack底層和構建流程的一些東西有一定的了解。
想要了解 webpack 的插件的機制,需要弄明白以下幾個知識點:
- 一個簡單的插件的構成
- webpack構建流程
- Tapable是如何把各個插件串聯到一起的
- compiler以及compilation對象的使用以及它們對應的事件鉤子。
插件基本結構
plugins是可以用自身原型方法Apply來實例化的對象。apply只在安裝插件被Webpack compiler執行一次。apply方法傳入一個webpck compiler的引用,來訪問編譯器回調。
一個簡單的插件結構:
class HelloPlugin{
// 在構造函數中獲取用戶給該插件傳入的配置
constructor(options){
}
// Webpack 會調用 HelloPlugin 實例的 apply 方法給插件實例傳入 compiler 對象
apply(compiler) {
// 在emit階段插入鉤子函數,用于特定時機處理額外的邏輯;
compiler.hooks.emit.tap('HelloPlugin', (compilation) => {
// 在功能流程完成后可以調用 webpack 提供的回調函數;
});
// 如果事件是異步的,會帶兩個參數,第二個參數為回調函數,在插件處理完任務時需要調用回調函數通知webpack,才會進入下一個處理流程。
compiler.plugin('emit',function(compilation, callback) {
// 支持處理邏輯
// 處理完畢后執行 callback 以通知 Webpack
// 如果不執行 callback,運行流程將會一直卡在這不往下執行
callback();
});
}
}
module.exports = HelloPlugin;
安裝插件時, 只需要將它的一個實例放到Webpack config plugins 數組里面:
const HelloPlugin = require('./hello-plugin.js');
var webpackConfig = {
plugins: [
new HelloPlugin({options: true})
]
};
先來分析一下webpack Plugin的工作原理
- 讀取配置的過程中會先執行 new HelloPlugin(options) 初始化一個 HelloPlugin 獲得其實例。
- 初始化 compiler 對象后調用 HelloPlugin.apply(compiler) 給插件實例傳入 compiler 對象。
- 插件實例在獲取到 compiler 對象后,就可以通過compiler.plugin(事件名稱, 回調函數) 監聽到 Webpack 廣播出來的事件。 并且可以通過 compiler 對象去操作 Webpack。
webapck 構建流程
在編寫插件之前,還需要了解一下Webpack的構建流程,以便在合適的時機插入合適的插件邏輯。
Webpack的基本構建流程如下:
- 校驗配置文件 :讀取命令行傳入或者webpack.config.js文件,初始化本次構建的配置參數
- 生成Compiler對象:執行配置文件中的插件實例化語句new MyWebpackPlugin(),為webpack事件流掛上自定義hooks
- 進入entryOption階段:webpack開始讀取配置的Entries,遞歸遍歷所有的入口文件
- run/watch:如果運行在watch模式則執行watch方法,否則執行run方法
- compilation:創建Compilation對象回調compilation相關鉤子,依次進入每一個入口文件(entry),使用loader對文件進行編譯。通過compilation我可以可以讀取到module的resource(資源路徑)、loaders(使用的loader)等信息。再將編譯好的文件內容使用acorn解析生成AST靜態語法樹。然后遞歸、重復的執行這個過程, 所有模塊和和依賴分析完成后,執行 compilation 的 seal 方法對每個 chunk 進行整理、優化、封裝__webpack_require__來模擬模塊化操作.
- emit:所有文件的編譯及轉化都已經完成,包含了最終輸出的資源,我們可以在傳入事件回調的compilation.assets上拿到所需數據,其中包括即將輸出的資源、代碼塊Chunk等等信息。
// 修改或添加資源
compilation.assets['new-file.js'] = {
source() {
return 'var a=1';
},
size() {
return this.source().length;
}
};
- afterEmit:文件已經寫入磁盤完成
- done:完成編譯
奉上一張滴滴云博客的WebPack 編譯流程圖,不喜歡看文字講解的可以看流程圖理解記憶
WebPack 編譯流程圖原圖出自:https://blog.didiyun.com/index.php/2019/03/01/webpack/
看完之后,如果還是看不懂或者對縷不清webpack構建流程的話,建議通讀一下全文,再回來看這段話,相信一定會對webpack構建流程有很更加深刻的理解。
理解事件流機制 Tapable
webpack本質上是一種事件流的機制,它的工作流程就是將各個插件串聯起來,而實現這一切的核心就是Tapable。
Webpack 的 Tapable 事件流機制保證了插件的有序性,將各個插件串聯起來, Webpack 在運行過程中會廣播事件,插件只需要監聽它所關心的事件,就能加入到這條webapck機制中,去改變webapck的運作,使得整個系統擴展性良好。
Tapable也是一個小型的 library,是Webpack的一個核心工具。類似于node中的events庫,核心原理就是一個訂閱發布模式。作用是提供類似的插件接口。
webpack中最核心的負責編譯的Compiler和負責創建bundles的Compilation都是Tapable的實例,可以直接在 Compiler 和 Compilation 對象上廣播和監聽事件,方法如下:
/**
* 廣播事件
* event-name 為事件名稱,注意不要和現有的事件重名
*/
compiler.apply('event-name',params);
compilation.apply('event-name',params);
/**
* 監聽事件
*/
compiler.plugin('event-name',function(params){});
compilation.plugin('event-name', function(params){});
Tapable類暴露了tap、tapAsync和tapPromise方法,可以根據鉤子的同步/異步方式來選擇一個函數注入邏輯。
tap 同步鉤子
compiler.hooks.compile.tap('MyPlugin', params => {
console.log('以同步方式觸及 compile 鉤子。')
})
tapAsync 異步鉤子,通過callback回調告訴Webpack異步執行完畢tapPromise 異步鉤子,返回一個Promise告訴Webpack異步執行完畢
compiler.hooks.run.tapAsync('MyPlugin', (compiler, callback) => {
console.log('以異步方式觸及 run 鉤子。')
callback()
})
compiler.hooks.run.tapPromise('MyPlugin', compiler => {
return new Promise(resolve => setTimeout(resolve, 1000)).then(() => {
console.log('以具有延遲的異步方式觸及 run 鉤子')
})
})
Tabable用法
const {
SyncHook,
SyncBailHook,
SyncWaterfallHook,
SyncLoopHook,
AsyncParallelHook,
AsyncParallelBailHook,
AsyncSeriesHook,
AsyncSeriesBailHook,
AsyncSeriesWaterfallHook
} = require("tapable");
tapable
簡單實現一個 SyncHook
class Hook{
constructor(args){
this.taps = []
this.interceptors = [] // 這個放在后面用
this._args = args
}
tap(name,fn){
this.taps.push({name,fn})
}
}
class SyncHook extends Hook{
call(name,fn){
try {
this.taps.forEach(tap => tap.fn(name))
fn(null,name)
} catch (error) {
fn(error)
}
}
}
tapable是如何將webapck/webpack插件關聯的?
Compiler.js
const { AsyncSeriesHook ,SyncHook } = require("tapable");
//創建類
class Compiler {
constructor() {
this.hooks = {
run: new AsyncSeriesHook(["compiler"]), //異步鉤子
compile: new SyncHook(["params"]),//同步鉤子
};
},
run(){
//執行異步鉤子
this.hooks.run.callAsync(this, err => {
this.compile(onCompiled);
});
},
compile(){
//執行同步鉤子 并傳參
this.hooks.compile.call(params);
}
}
module.exports = Compiler
MyPlugin.js
const Compiler = require('./Compiler')
class MyPlugin{
apply(compiler){//接受 compiler參數
compiler.hooks.run.tap("MyPlugin", () => console.log('開始編譯...'));
compiler.hooks.compile.tapAsync('MyPlugin', (name, age) => {
setTimeout(() => {
console.log('編譯中...')
}, 1000)
});
}
}
//這里類似于webpack.config.js的plugins配置
//向 plugins 屬性傳入 new 實例
const myPlugin = new MyPlugin();
const options = {
plugins: [myPlugin]
}
let compiler = new Compiler(options)
compiler.run()
想要深入了解tapable的文章可以看看這篇文章:
webpack4核心模塊tapable源碼解析: https://www.cnblogs.com/tugenhua0707/p/11317557.html
理解Compiler(負責編譯)
開發插件首先要知道compiler和 compilation 對象是做什么的
Compiler 對象包含了當前運行Webpack的配置,包括entry、output、loaders等配置,這個對象在啟動Webpack時被實例化,而且是全局唯一的。Plugin可以通過該對象獲取到Webpack的配置信息進行處理。
如果看完這段話,你還是沒理解compiler是做啥的,不要怕,接著看。 運行npm run build,把compiler的全部信息輸出到控制臺上console.log(Compiler)。
compiler
// 為了能更直觀的讓大家看清楚compiler的結構,里面的大量代碼使用省略號(...)代替。
Compiler {
_pluginCompat: SyncBailHook {
...
},
hooks: {
shouldEmit: SyncBailHook {
...
},
done: AsyncSeriesHook {
...
},
additionalPass: AsyncSeriesHook {
...
},
beforeRun: AsyncSeriesHook {
...
},
run: AsyncSeriesHook {
...
},
emit: AsyncSeriesHook {
...
},
assetEmitted: AsyncSeriesHook {
...
},
afterEmit: AsyncSeriesHook {
...
},
thisCompilation: SyncHook {
...
},
compilation: SyncHook {
...
},
normalModuleFactory: SyncHook {
...
},
contextModuleFactory: SyncHook {
...
},
beforeCompile: AsyncSeriesHook {
...
},
compile: SyncHook {
...
},
make: AsyncParallelHook {
...
},
afterCompile: AsyncSeriesHook {
...
},
watchRun: AsyncSeriesHook {
...
},
failed: SyncHook {
...
},
invalid: SyncHook {
...
},
watchClose: SyncHook {
...
},
infrastructureLog: SyncBailHook {
...
},
environment: SyncHook {
...
},
afterEnvironment: SyncHook {
...
},
afterPlugins: SyncHook {
...
},
afterResolvers: SyncHook {
...
},
entryOption: SyncBailHook {
...
},
infrastructurelog: SyncBailHook {
...
}
},
...
outputPath: '',//輸出目錄
outputFileSystem: NodeOutputFileSystem {
...
},
inputFileSystem: CachedInputFileSystem {
...
},
...
options: {
//Compiler對象包含了webpack的所有配置信息,entry、module、output、resolve等信息
entry: [
'babel-polyfill',
'/Users/frank/Desktop/fe/fe-blog/webpack-plugin/src/index.js'
],
devServer: { port: 3000 },
output: {
...
},
module: {
...
},
plugins: [ MyWebpackPlugin {} ],
mode: 'production',
context: '/Users/frank/Desktop/fe/fe-blog/webpack-plugin',
devtool: false,
...
performance: {
maxAssetSize: 250000,
maxEntrypointSize: 250000,
hints: 'warning'
},
optimization: {
...
},
resolve: {
...
},
resolveLoader: {
...
},
infrastructureLogging: { level: 'info', debug: false }
},
context: '/Users/frank/Desktop/fe/fe-blog/webpack-plugin',//上下文,文件目錄
requestShortener: RequestShortener {
...
},
...
watchFileSystem: NodeWatchFileSystem {
//監聽文件變化列表信息
...
}
}
Compiler源碼精簡版代碼解析
源碼地址(948行):https://github.com/webpack/webpack/blob/master/lib/Compiler.js
const { SyncHook, SyncBailHook, AsyncSeriesHook } = require("tapable");
class Compiler {
constructor() {
// 1. 定義生命周期鉤子
this.hooks = Object.freeze({
// ...只列舉幾個常用的常見鉤子,更多hook就不列舉了,有興趣看源碼
done: new AsyncSeriesHook(["stats"]),//一次編譯完成后執行,回調參數:stats
beforeRun: new AsyncSeriesHook(["compiler"]),
run: new AsyncSeriesHook(["compiler"]),//在編譯器開始讀取記錄前執行
emit: new AsyncSeriesHook(["compilation"]),//在生成文件到output目錄之前執行,回調參數: compilation
afterEmit: new AsyncSeriesHook(["compilation"]),//在生成文件到output目錄之后執行
compilation: new SyncHook(["compilation", "params"]),//在一次compilation創建后執行插件
beforeCompile: new AsyncSeriesHook(["params"]),
compile: new SyncHook(["params"]),//在一個新的compilation創建之前執行
make:new AsyncParallelHook(["compilation"]),//完成一次編譯之前執行
afterCompile: new AsyncSeriesHook(["compilation"]),
watchRun: new AsyncSeriesHook(["compiler"]),
failed: new SyncHook(["error"]),
watchClose: new SyncHook([]),
afterPlugins: new SyncHook(["compiler"]),
entryOption: new SyncBailHook(["context", "entry"])
});
// ...省略代碼
}
newCompilation() {
// 創建Compilation對象回調compilation相關鉤子
const compilation = new Compilation(this);
//...一系列操作
this.hooks.compilation.call(compilation, params); //compilation對象創建完成
return compilation
}
watch() {
//如果運行在watch模式則執行watch方法,否則執行run方法
if (this.running) {
return handler(new ConcurrentCompilationError());
}
this.running = true;
this.watchMode = true;
return new Watching(this, watchOptions, handler);
}
run(callback) {
if (this.running) {
return callback(new ConcurrentCompilationError());
}
this.running = true;
process.nextTick(() => {
this.emitAssets(compilation, err => {
if (err) {
// 在編譯和輸出的流程中遇到異常時,會觸發 failed 事件
this.hooks.failed.call(err)
};
if (compilation.hooks.needAdditionalPass.call()) {
// ...
// done:完成編譯
this.hooks.done.callAsync(stats, err => {
// 創建compilation對象之前
this.compile(onCompiled);
});
}
this.emitRecords(err => {
this.hooks.done.callAsync(stats, err => {
});
});
});
});
this.hooks.beforeRun.callAsync(this, err => {
this.hooks.run.callAsync(this, err => {
this.readRecords(err => {
this.compile(onCompiled);
});
});
});
}
compile(callback) {
const params = this.newCompilationParams();
this.hooks.beforeCompile.callAsync(params, err => {
this.hooks.compile.call(params);
const compilation = this.newCompilation(params);
//觸發make事件并調用addEntry,找到入口js,進行下一步
this.hooks.make.callAsync(compilation, err => {
process.nextTick(() => {
compilation.finish(err => {
// 封裝構建結果(seal),逐次對每個module和chunk進行整理,每個chunk對應一個入口文件
compilation.seal(err => {
this.hooks.afterCompile.callAsync(compilation, err => {
// 異步的事件需要在插件處理完任務時調用回調函數通知 Webpack 進入下一個流程,
// 不然運行流程將會一直卡在這不往下執行
return callback(null, compilation);
});
});
});
});
});
});
}
emitAssets(compilation, callback) {
const emitFiles = (err) => {
//...省略一系列代碼
// afterEmit:文件已經寫入磁盤完成
this.hooks.afterEmit.callAsync(compilation, err => {
if (err) return callback(err);
return callback();
});
}
// emit 事件發生時,可以讀取到最終輸出的資源、代碼塊、模塊及其依賴,并進行修改(這是最后一次修改最終文件的機會)
this.hooks.emit.callAsync(compilation, err => {
if (err) return callback(err);
outputPath = compilation.getPath(this.outputPath, {});
mkdirp(this.outputFileSystem, outputPath, emitFiles);
});
}
// ...省略代碼
}
apply方法中插入鉤子的一般形式如下:
// compiler提供了compiler.hooks,可以根據這些不同的時刻去讓插件做不同的事情。
compiler.hooks.階段.tap函數('插件名稱', (階段回調參數) => {
});
compiler.run(callback)
理解Compilation
Compilation對象代表了一次資源版本構建。當運行 webpack 開發環境中間件時,每當檢測到一個文件變化,就會創建一個新的 compilation,從而生成一組新的編譯資源。一個 Compilation 對象表現了當前的模塊資源、編譯生成資源、變化的文件、以及被跟蹤依賴的狀態信息,簡單來講就是把本次打包編譯的內容存到內存里。Compilation 對象也提供了插件需要自定義功能的回調,以供插件做自定義處理時選擇使用拓展。
簡單來說,Compilation的職責就是構建模塊和Chunk,并利用插件優化構建過程。
和 Compiler 用法相同,鉤子類型不同,也可以在某些鉤子上訪問 tapAsync和 tapPromise。
控制臺輸出console.log(compilation)
通過 Compilation 也能讀取到 Compiler 對象。
源碼2000多行,看不動了- -,有興趣的可以自己看看。 https://github.com/webpack/webpack/blob/master/lib/Compilation.js
介紹幾個常用的Compilation Hooks
buildModule(SyncHook):在模塊開始編譯之前觸發,可以用于修改模
succeedModule(SyncHook):在模塊開始編譯之前觸發,可以用于修改模塊
finishModules(AsyncSeriesHook):當所有模塊都編譯成功后被調用
seal(SyncHook):當一次compilation停止接收新模塊時觸發
optimizeDependencies(SyncBailHook):在依賴優化的開始執行
optimize(SyncHook):在優化階段的開始執行
optimizeModules(SyncBailHook):在模塊優化階段開始時執行,插件可以在這個鉤子里執行對模塊的優化,回調參數:modules
optimizeChunks(SyncBailHook):在代碼塊優化階段開始時執行,插件可以在這個鉤子里執行對代碼塊的優化,回調參數:chunks
optimizeChunkAssets(AsyncSeriesHook):優化任何代碼塊資源,這些資源存放在compilation.assets 上。一個 chunk 有一個 files 屬性,它指向由一個chunk創建的所有文件。任何額外的 chunk 資源都存放在 compilation.additionalChunkAssets 上。回調參數:chunks
optimizeAssets(AsyncSeriesHook):優化所有存放在 compilation.assets的所有資源。回調參數:assets |
Compiler 和 Compilation 的區別
Compiler 代表了整個 Webpack 從啟動到關閉的生命周期,而 Compilation只是代表了一次新的編譯,只要文件有改動,compilation就會被重新創建。
常用 API
插件可以用來修改輸出文件、增加輸出文件、甚至可以提升 Webpack 性能、等等,總之插件通過調用Webpack 提供的 API 能完成很多事情。 由于 Webpack提供的 API 非常多,有很多 API 很少用的上,又加上篇幅有限,下面來介紹一些常用的 API。
讀取輸出資源、代碼塊、模塊及其依賴
有些插件可能需要讀取 Webpack 的處理結果,例如輸出資源、代碼塊、模塊及其依賴,以便做下一步處理。 在 emit 事件發生時,代表源文件的轉換和組裝已經完成,在這里可以讀取到最終將輸出的資源、代碼塊、模塊及其依賴,并且可以修改輸出資源的內容。 插件代碼如下:
class Plugin {
apply(compiler) {
compiler.plugin('emit', function (compilation, callback) {
// compilation.chunks 存放所有代碼塊,是一個數組
compilation.chunks.forEach(function (chunk) {
// chunk 代表一個代碼塊
// 代碼塊由多個模塊組成,通過 chunk.forEachModule 能讀取組成代碼塊的每個模塊
chunk.forEachModule(function (module) {
// module 代表一個模塊
// module.fileDependencies 存放當前模塊的所有依賴的文件路徑,是一個數組
module.fileDependencies.forEach(function (filepath) {
});
});
// Webpack 會根據 Chunk 去生成輸出的文件資源,每個 Chunk 都對應一個及其以上的輸出文件
// 例如在 Chunk 中包含了 css 模塊并且使用了 ExtractTextPlugin 時,
// 該 Chunk 就會生成 .js 和 .css 兩個文件
chunk.files.forEach(function (filename) {
// compilation.assets 存放當前所有即將輸出的資源
// 調用一個輸出資源的 source() 方法能獲取到輸出資源的內容
let source = compilation.assets[filename].source();
});
});
// 這是一個異步事件,要記得調用 callback 通知 Webpack 本次事件監聽處理結束。
// 如果忘記了調用 callback,Webpack 將一直卡在這里而不會往后執行。
callback();
})
}
}
監聽文件變化
Webpack 會從配置的入口模塊出發,依次找出所有的依賴模塊,當入口模塊或者其依賴的模塊發生變化時, 就會觸發一次新的 Compilation。
在開發插件時經常需要知道是哪個文件發生變化導致了新的 Compilation,為此可以使用如下代碼:
// 當依賴的文件發生變化時會觸發 watch-run 事件
compiler.hooks.watchRun.tap('MyPlugin', (watching, callback) => {
// 獲取發生變化的文件列表
const changedFiles = watching.compiler.watchFileSystem.watcher.mtimes;
// changedFiles 格式為鍵值對,鍵為發生變化的文件路徑。
if (changedFiles[filePath] !== undefined) {
// filePath 對應的文件發生了變化
}
callback();
});
默認情況下 Webpack 只會監視入口和其依賴的模塊是否發生變化,在有些情況下項目可能需要引入新的文件,例如引入一個 HTML 文件。 由于 JAVAScript 文件不會去導入 HTML 文件,Webpack 就不會監聽 HTML 文件的變化,編輯 HTML 文件時就不會重新觸發新的 Compilation。 為了監聽 HTML 文件的變化,我們需要把 HTML 文件加入到依賴列表中,為此可以使用如下代碼:
compiler.hooks.afterCompile.tap('MyPlugin', (compilation, callback) => {
// 把 HTML 文件添加到文件依賴列表,好讓 Webpack 去監聽 HTML 模塊文件,在 HTML 模版文件發生變化時重新啟動一次編譯
compilation.fileDependencies.push(filePath);
callback();
});
3、修改輸出資源
有些場景下插件需要修改、增加、刪除輸出的資源,要做到這點需要監聽emit 事件,因為發生 emit 事件時所有模塊的轉換和代碼塊對應的文件已經生成好, 需要輸出的資源即將輸出,因此emit事件是修改 Webpack 輸出資源的最后時機。
所有需要輸出的資源會存放在 compilation.assets中,compilation.assets 是一個鍵值對,鍵為需要輸出的文件名稱,值為文件對應的內容。
設置 compilation.assets 的代碼如下:
// 設置名稱為 fileName 的輸出資源
compilation.assets[fileName] = {
// 返回文件內容
source: () => {
// fileContent 既可以是代表文本文件的字符串,也可以是代表二進制文件的 Buffer
return fileContent;
},
// 返回文件大小
size: () => {
return Buffer.byteLength(fileContent, 'utf8');
}
};
callback();
判斷webpack使用了哪些插件
// 判斷當前配置使用使用了 ExtractTextPlugin,
// compiler 參數即為 Webpack 在 apply(compiler) 中傳入的參數
function hasExtractTextPlugin(compiler) {
// 當前配置所有使用的插件列表
const plugins = compiler.options.plugins;
// 去 plugins 中尋找有沒有 ExtractTextPlugin 的實例
return plugins.find(plugin=>plugin.__proto__.constructor === ExtractTextPlugin) != null;
}
以上4種方法來源于文章: [Webpack學習-Plugin] :http://wushaobin.top/2019/03/15/webpackPlugin/
管理 Warnings 和 Errors
做一個實驗,如果你在 apply函數內插入 throw new Error("Message"),會發生什么,終端會打印出 Unhandled rejection Error: Message。然后 webpack 中斷執行。 為了不影響 webpack 的執行,要在編譯期間向用戶發出警告或錯誤消息,則應使用 compilation.warnings 和 compilation.errors。
compilation.warnings.push("warning");
compilation.errors.push("error");
文章中的案例demo代碼展示
https://github.com/6fedcom/fe-blog/tree/master/webpack/plugin
webpack打包過程或者插件代碼里該如何調試?
- 在當前webpack項目工程文件夾下面,執行命令行:
node --inspect-brk ./node_modules/webpack/bin/webpack.js --inline --progress
其中參數--inspect-brk就是以調試模式啟動node:
終端會輸出:
Debugger listening on ws://127.0.0.1:9229/1018c03f-7473-4d60-b62c-949a6404c81d
For help, see: https://nodejs.org/en/docs/inspector
- 谷歌瀏覽器輸入 chrome://inspect/#devices
點擊inspect
- 然后點一下Chrome調試器里的“繼續執行”,斷點就提留在我們設置在插件里的debugger斷點了。