乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)
轉載自掘金: ofollow,noindex">https://juejin.im/post/5beb8875e51d455e5c4dd83f?utm_source=tuicool&utm_medium=referral#comment
目錄
- Tabable是什麼?
- Tabable 用法
- 進階一下
- Tabable的其他方法
- webpack流程
- 總結
- 實戰!寫一個外掛
Webpack可以將其理解是一種基於事件流的程式設計範例,一個外掛合集。
而將這些外掛控制在webapck事件流上的執行的就是webpack自己寫的基礎類 Tapable
。
Tapable暴露出掛載 plugin
的方法,使我們能 將plugin控制在webapack事件流上執行(如下圖)。後面我們將看到核心的物件 Compiler
、 Compilation
等都是繼承於 Tabable
類。(如下圖所示)

Tabable是什麼?
tapable庫 暴露了很多Hook(鉤子)類,為外掛提供掛載的鉤子。
const { SyncHook, SyncBailHook, SyncWaterfallHook, SyncLoopHook, AsyncParallelHook, AsyncParallelBailHook, AsyncSeriesHook, AsyncSeriesBailHook, AsyncSeriesWaterfallHook } = require("tapable");

Tabable 用法
- 1.new Hook 新建鉤子
- tapable 暴露出來的都是類方法,new 一個類方法獲得我們需要的鉤子。
- class 接受陣列引數options,非必傳。類方法會根據傳參,接受同樣數量的引數。
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]);
- 2.使用 tap/tapAsync/tapPromise 繫結鉤子
tabpack提供了 同步
& 非同步
繫結鉤子的方法,並且他們都有 繫結事件
和 執行事件
對應的方法。
Async* | Sync* |
---|---|
繫結:tapAsync/tapPromise/tap | 繫結:tap |
執行:callAsync/promise | 執行:call |
- 3.call/callAsync 執行繫結事件
const hook1 = new SyncHook(["arg1", "arg2", "arg3"]); //繫結事件到webapck事件流 hook1.tap('hook1', (arg1, arg2, arg3) => console.log(arg1, arg2, arg3)) //1,2,3 //執行繫結的事件 hook1.call(1,2,3)

- 舉個栗子
- 定義一個Car方法,在內部hooks上新建鉤子。分別是
同步鉤子
accelerate、break(accelerate接受一個引數)、非同步鉤子
calculateRoutes - 使用鉤子對應的
繫結和執行方法
- calculateRoutes使用
tapPromise
可以返回一個promise
物件。
- 定義一個Car方法,在內部hooks上新建鉤子。分別是
//引入tapable const { SyncHook, AsyncParallelHook } = require('tapable'); //建立類 class Car { constructor() { this.hooks = { accelerate: new SyncHook(["newSpeed"]), break: new SyncHook(), calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"]) }; } } const myCar = new Car(); //繫結同步鉤子 myCar.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin')); //繫結同步鉤子 並傳參 myCar.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`)); //繫結一個非同步Promise鉤子 myCar.hooks.calculateRoutes.tapPromise("calculateRoutes tapPromise", (source, target, routesList, callback) => { // return a promise return new Promise((resolve,reject)=>{ setTimeout(()=>{ console.log(`tapPromise to ${source}${target}${routesList}`) resolve(); },1000) }) }); //執行同步鉤子 myCar.hooks.break.call(); myCar.hooks.accelerate.call('hello'); console.time('cost'); //執行非同步鉤子 myCar.hooks.calculateRoutes.promise('i', 'love', 'tapable').then(() => { console.timeEnd('cost'); }, err => { console.error(err); console.timeEnd('cost'); })
執行結果
WarningLampPlugin Accelerating to hello tapPromise to ilovetapable cost: 1003.898ms
calculateRoutes也可以使用 tapAsync
繫結鉤子,注意:此時用 callback
結束非同步回撥。
myCar.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => { // return a promise setTimeout(() => { console.log(`tapAsync to ${source}${target}${routesList}`) callback(); }, 2000) }); myCar.hooks.calculateRoutes.callAsync('i', 'like', 'tapable', err => { console.timeEnd('cost'); if(err) console.log(err) })
執行結果
WarningLampPlugin Accelerating to hello tapAsync to iliketapable cost: 2007.850ms
進階一下~
到這裡可能已經學會使用tapable了,但是它如何與webapck/webpack外掛關聯呢?
我們將剛才的程式碼稍作改動,拆成兩個檔案:Compiler.js、Myplugin.js
Compiler.js
Compiler
const { SyncHook, AsyncParallelHook } = require('tapable'); class Compiler { constructor(options) { this.hooks = { accelerate: new SyncHook(["newSpeed"]), break: new SyncHook(), calculateRoutes: new AsyncParallelHook(["source", "target", "routesList"]) }; let plugins = options.plugins; if (plugins && plugins.length > 0) { plugins.forEach(plugin => plugin.apply(this)); } } run(){ console.time('cost'); this.accelerate('hello') this.break() this.calculateRoutes('i', 'like', 'tapable') } accelerate(param){ this.hooks.accelerate.call(param); } break(){ this.hooks.break.call(); } calculateRoutes(){ const args = Array.from(arguments) this.hooks.calculateRoutes.callAsync(...args, err => { console.timeEnd('cost'); if (err) console.log(err) }); } } module.exports = Compiler
MyPlugin.js
- 引入Compiler
- 定義一個自己的外掛。
- apply方法接受 compiler引數。
webpack 外掛是一個具有 apply
方法的 JavaScript 物件。 apply 屬性會被 webpack compiler 呼叫
,並且 compiler 物件可在整個編譯生命週期訪問。
向 plugins 屬性傳入 new 例項
const Compiler = require('./Compiler') class MyPlugin{ constructor() { } apply(conpiler){//接受 compiler引數 conpiler.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin')); conpiler.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`)); conpiler.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => { setTimeout(() => { console.log(`tapAsync to ${source}${target}${routesList}`) callback(); }, 2000) }); } } //這裡類似於webpack.config.js的plugins配置 //向 plugins 屬性傳入 new 例項 const myPlugin = new MyPlugin(); const options = { plugins: [myPlugin] } let compiler = new Compiler(options) compiler.run()
執行結果
Accelerating to hello WarningLampPlugin tapAsync to iliketapable cost: 2015.866ms
改造後執行正常,仿照Compiler和webpack外掛的思路慢慢得理順外掛的邏輯成功。
Tabable的其他方法
type | function |
---|---|
Hook | 所有鉤子的字尾 |
Waterfall | 同步方法,但是它會傳值給下一個函式 |
Bail | 熔斷:當函式有任何返回值,就會在當前執行函式停止 |
Loop | 監聽函式返回true表示繼續迴圈,返回undefine表示結束迴圈 |
Sync | 同步方法 |
AsyncSeries | 非同步序列鉤子 |
AsyncParallel | 非同步並行執行鉤子 |
我們可以根據自己的開發需求,選擇適合的同步/非同步鉤子。
webpack流程
通過上面的閱讀,我們知道了如何在webapck事件流上掛載鉤子。
假設現在要自定義一個外掛更改最後產出資源的內容,我們應該把事件新增在哪個鉤子上呢?哪一個步驟能拿到webpack編譯的資源從而去修改?
所以接下來的任務是:瞭解webpack的流程。
貼一張淘寶團隊分享的經典webpack流程圖,再慢慢分析~

1. webpack入口 (webpack.config.js+shell options)
從配置檔案package.json 和 Shell 語句中讀取與合併引數,得出最終的引數;
每次在命令列輸入 webpack 後,作業系統都會去呼叫 ./node_modules/.bin/webpack
這個 shell 指令碼。這個指令碼會去呼叫 ./node_modules/webpack/bin/webpack.js
並追加輸入的引數,如 -p , -w 。
2. 用yargs引數解析 (optimist)
yargs.parse(process.argv.slice(2), (err, argv, output) => {})
3.webpack初始化
(1)構建compiler物件
let compiler = new Webpack(options)
(2)註冊NOdeEnvironmentPlugin外掛
new NodeEnvironmentPlugin().apply(compiler);
(3)掛在options中的基礎外掛,呼叫 WebpackOptionsApply
庫初始化基礎外掛。
if (options.plugins && Array.isArray(options.plugins)) { for (const plugin of options.plugins) { if (typeof plugin === "function") { plugin.apply(compiler); } else { plugin.apply(compiler); } } } compiler.hooks.environment.call(); compiler.hooks.afterEnvironment.call(); compiler.options = new WebpackOptionsApply().process(options, compiler);
4. run
開始編譯
if (firstOptions.watch || options.watch) { const watchOptions = firstOptions.watchOptions || firstOptions.watch || options.watch || {}; if (watchOptions.stdin) { process.stdin.on("end", function(_) { process.exit(); // eslint-disable-line }); process.stdin.resume(); } compiler.watch(watchOptions, compilerCallback); if (outputOptions.infoVerbosity !== "none") console.log("\nwebpack is watching the files…\n"); } else compiler.run(compilerCallback);
這裡分為兩種情況:
1)Watching:監聽檔案變化
2)run:執行編譯
5.觸發 compile
(1)在run的過程中,已經觸發了一些鉤子: beforeRun->run->beforeCompile->compile->make->seal
(編寫外掛的時候,就可以將自定義的方掛在對應鉤子上,按照編譯的順序被執行)
(2)構建了關鍵的 Compilation
物件
在run()方法中,執行了this.compile()
this.compile()中建立了compilation
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); this.hooks.make.callAsync(compilation, err => { ... compilation.finish(); compilation.seal(err => { ... this.hooks.afterCompile.callAsync(compilation, err ... return callback(null, compilation); }); }); }); }); }
const compilation = this.newCompilation(params);
Compilation
負責整個編譯過程,包含了每個構建環節所對應的方法。物件內部保留了對compiler的引用。
當 Webpack 以開發模式執行時,每當檢測到檔案變化,一次新的 Compilation 將被建立。
劃重點:Compilation很重要!編譯生產資源變換檔案都靠它。
6.addEntry() make 分析入口檔案建立模組物件
compile中觸發 make
事件並呼叫 addEntry
webpack的make鉤子中, tapAsync註冊了一個 DllEntryPlugin
, 就是將入口模組通過呼叫compilation。
這一註冊在Compiler.compile()方法中被執行。
addEntry方法將所有的入口模組新增到編譯構建佇列中,開啟編譯流程。
DllEntryPlugin.js
compiler.hooks.make.tapAsync("DllEntryPlugin", (compilation, callback) => { compilation.addEntry( this.context, new DllEntryDependency( this.entries.map((e, idx) => { const dep = new SingleEntryDependency(e); dep.loc = { name: this.name, index: idx }; return dep; }), this.name ), this.name, callback ); });
流程走到這裡讓我覺得很奇怪:剛剛還在Compiler.js中執行compile,怎麼一下子就到了DllEntryPlugin.js?
這就要說道之前 WebpackOptionsApply.process()初始化外掛的時候
,執行了 compiler.hooks.entryOption.call(options.context, options.entry)
;
WebpackOptionsApply.js
class WebpackOptionsApply extends OptionsApply { process(options, compiler) { ... compiler.hooks.entryOption.call(options.context, options.entry); } }
DllPlugin.js
compiler.hooks.entryOption.tap("DllPlugin", (context, entry) => { const itemToPlugin = (item, name) => { if (Array.isArray(item)) { return new DllEntryPlugin(context, item, name); } throw new Error("DllPlugin: supply an Array as entry"); }; if (typeof entry === "object" && !Array.isArray(entry)) { Object.keys(entry).forEach(name => { itemToPlugin(entry[name], name).apply(compiler); }); } else { itemToPlugin(entry, "main").apply(compiler); } return true; });
其實addEntry方法,存在很多入口,SingleEntryPlugin也註冊了compiler.hooks.make.tapAsync鉤子。這裡主要再強調一下 WebpackOptionsApply.process()
流程(233)。
入口有很多,有興趣可以除錯一下先後順序~
7. 構建模組
compilation.addEntry
中執行 _addModuleChain()
這個方法主要做了兩件事情。一是根據模組的型別獲取對應的模組工廠並建立模組,二是構建模組。
通過 *ModuleFactory.create方法建立模組,(有NormalModule , MultiModule , ContextModule , DelegatedModule 等)對模組使用的loader進行載入。呼叫 acorn 解析經 loader 處理後的原始檔生成抽象語法樹 AST。遍歷 AST,構建該模組所依賴的模組
addEntry(context, entry, name, callback) { const slot = { name: name, request: entry.request, module: null }; this._preparedEntrypoints.push(slot); this._addModuleChain( context, entry, module => { this.entries.push(module); }, (err, module) => { if (err) { return callback(err); } if (module) { slot.module = module; } else { const idx = this._preparedEntrypoints.indexOf(slot); this._preparedEntrypoints.splice(idx, 1); } return callback(null, module); } ); }
addEntry addModuleChain()原始碼地址
8. 封裝構建結果(seal)
webpack 會監聽 seal事件呼叫各外掛對構建後的結果進行封裝,要逐次對每個 module 和 chunk 進行整理,生成編譯後的原始碼,合併,拆分,生成 hash 。 同時這是我們在開發時進行程式碼優化和功能新增的關鍵環節。
template.getRenderMainfest.render()
通過模板(MainTemplate、ChunkTemplate)把chunk生產_webpack_requie()的格式。
9. 輸出資源(emit)
把Assets輸出到output的path中。
總結
webpack是一個外掛合集,由 tapable 控制各外掛在 webpack 事件流上執行。主要依賴的是compilation的編譯模組和封裝。
webpack 的入口檔案其實就例項了Compiler並呼叫了run方法開啟了編譯,webpack的主要編譯都按照下面的鉤子呼叫順序執行。
- Compiler:beforeRun 清除快取
- Compiler:run 註冊快取資料鉤子
- Compiler:beforeCompile
- Compiler:compile 開始編譯
- Compiler:make 從入口分析依賴以及間接依賴模組,建立模組物件
- Compilation:buildModule 模組構建
- Compiler:normalModuleFactory 構建
- Compilation:seal 構建結果封裝, 不可再更改
- Compiler:afterCompile 完成構建,快取資料
- Compiler:emit 輸出到dist目錄
一個 Compilation 物件包含了當前的模組資源、編譯生成資源、變化的檔案等。
Compilation 物件也提供了很多事件回撥供外掛做擴充套件。
Compilation中比較重要的部分是assets 如果我們要藉助webpack幫你生成檔案,就要在assets上新增對應的檔案資訊。
compilation.getStats()能得到生產檔案以及chunkhash的一些資訊。等等
實戰!寫一個外掛
這次嘗試寫一個簡單的外掛,幫助我們去除webpack打包生成的bundle.js中多餘的註釋
<figure style="display: block; margin: 22px auto; text-align: center;">[圖片上傳中...(image-2d5386-1542186773727-1)]
<figcaption style="display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>
</figure>
怎麼寫一個外掛?
參照webpack官方教程 Writing a Plugin
一個webpack plugin由一下幾個步驟組成:
- 一個JavaScript類函式。
- 在函式原型 (prototype)中定義一個注入
compiler
物件的apply
方法。 -
apply
函式中通過compiler插入指定的事件鉤子,在鉤子回撥中拿到compilation物件 - 使用compilation操縱修改webapack內部例項資料。
- 非同步外掛,資料處理完後使用callback回撥
完成外掛初始架構
在之前說Tapable的時候,寫了一個MyPlugin類函式,它已經滿足了webpack plugin結構的前兩點(一個JavaScript類函式,在函式原型 (prototype)中定義一個注入 compiler
)
現在我們要讓Myplugin滿足後三點。首先,使用compiler指定的事件鉤子。
class MyPlugin{ constructor() { } apply(conpiler){ conpiler.hooks.break.tap("WarningLampPlugin", () => console.log('WarningLampPlugin')); conpiler.hooks.accelerate.tap("LoggerPlugin", newSpeed => console.log(`Accelerating to ${newSpeed}`)); conpiler.hooks.calculateRoutes.tapAsync("calculateRoutes tapAsync", (source, target, routesList, callback) => { setTimeout(() => { console.log(`tapAsync to ${source}${target}${routesList}`) callback(); }, 2000) }); } }
外掛的常用物件
物件 | 鉤子 |
---|---|
Compiler | run,compile,compilation,make,emit,done |
Compilation | buildModule,normalModuleLoader,succeedModule,finishModules,seal,optimize,after-seal |
Module Factory | beforeResolver,afterResolver,module,parser |
Module | |
Parser | program,statement,call,expression |
Template | hash,bootstrap,localVars,render |
編寫外掛
class MyPlugin { constructor(options) { this.options = options this.externalModules = {} } apply(compiler) { var reg = /("([^\\\"]*(\\.)?)*")|('([^\\\']*(\\.)?)*')|(\/{2,}.*?(\r|\n))|(\/\*(\n|.)*?\*\/)|(\/\*\*\*\*\*\*\/)/g compiler.hooks.emit.tap('CodeBeautify', (compilation)=> { Object.keys(compilation.assets).forEach((data)=> { let content = compilation.assets[data].source() // 欲處理的文字 content = content.replace(reg, function (word) { // 去除註釋後的文字 return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word; }); compilation.assets[data] = { source(){ return content }, size(){ return content.length } } }) }) } } module.exports = MyPlugin
第一步,使用compiler的emit鉤子
emit事件是將編譯好的程式碼發射到指定的stream中觸發,在這個鉤子執行的時候,我們能從回撥函式返回的compilation物件上拿到編譯好的stream。
compiler.hooks.emit.tap('xxx',(compilation)=>{})
第二步,訪問compilation物件,我們用繫結提供了編譯 compilation 引用的emit鉤子函式,每一次編譯都會拿到新的 compilation 物件。這些 compilation 物件提供了一些鉤子函式,來鉤入到構建流程的很多步驟中。
compilation中會返回很多內部物件,不完全截圖如下所示:
<figure style="display: block; margin: 22px auto; text-align: center;">[圖片上傳中...(image-982c4e-1542186773727-0)]
<figcaption style="display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>
</figure>
其中,我們需要的是 compilation.assets
assetsCompilation { assets: { 'js/index/main.js': CachedSource { _source: [Object], _cachedSource: undefined, _cachedSize: undefined, _cachedMaps: {} } }, errors: [], warnings: [], children: [], dependencyFactories: ArrayMap { keys: [ [Object], [Function: MultiEntryDependency], [Function: SingleEntryDependency], [Function: LoaderDependency], [Object], [Function: ContextElementDependency], values: [ NullFactory {}, [Object], NullFactory {} ] }, dependencyTemplates: ArrayMap { keys: [ [Object], [Object], [Object] ], values: [ ConstDependencyTemplate {}, RequireIncludeDependencyTemplate {}, NullDependencyTemplate {}, RequireEnsureDependencyTemplate {}, ModuleDependencyTemplateAsRequireId {}, AMDRequireDependencyTemplate {}, ModuleDependencyTemplateAsRequireId {}, AMDRequireArrayDependencyTemplate {}, ContextDependencyTemplateAsRequireCall {}, AMDRequireDependencyTemplate {}, LocalModuleDependencyTemplate {}, ModuleDependencyTemplateAsId {}, ContextDependencyTemplateAsRequireCall {}, ModuleDependencyTemplateAsId {}, ContextDependencyTemplateAsId {}, RequireResolveHeaderDependencyTemplate {}, RequireHeaderDependencyTemplate {} ] }, fileTimestamps: {}, contextTimestamps: {}, name: undefined, _currentPluginApply: undefined, fullHash: 'f4030c2aeb811dd6c345ea11a92f4f57', hash: 'f4030c2aeb811dd6c345', fileDependencies: [ '/Users/mac/web/src/js/index/main.js' ], contextDependencies: [], missingDependencies: [] }
優化所有 chunk 資源(asset)。資源(asset)會以key-value的形式被儲存在 compilation.assets
。
第三步,遍歷assets。
1)assets陣列物件中的key是資源名,在Myplugin外掛中,遍歷Object.key()我們拿到了
main.css bundle.js index.html
2)呼叫Object.source() 方法,得到資源的內容
compilation.assets[data].source()
3)用正則,去除註釋
Object.keys(compilation.assets).forEach((data)=> { let content = compilation.assets[data].source() content = content.replace(reg, function (word) { return /^\/{2,}/.test(word) || /^\/\*!/.test(word) || /^\/\*{3,}\//.test(word) ? "" : word; }) });
第四步,更新compilation.assets[data]物件
compilation.assets[data] = { source(){ return content }, size(){ return content.length } }
第五步 在webpack中引用外掛
webpack.config.js
const path= require('path') const MyPlugin = require('./plugins/MyPlugin') module.exports = { entry:'./src/index.js', output:{ path:path.resolve('dist'), filename:'bundle.js' }, plugins:[ ... new MyPlugin() ] }
參考資料