原文鏈接:https://www.jianshu.com/p/715...
目錄
- 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
- 把Class Car類名改成webpack的核心
Compiler
- 接受options裏傳入的plugins
- 將Compiler作爲參數傳給plugin
- 執行run函數,在編譯的每個階段,都觸發執行相對應的鉤子函數。
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 對象可在整個編譯生命週期訪問。
- 給compiler上的鉤子綁定方法。
- 仿照webpack規則,
向 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);
}
);
}
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()
]
}
參考資料