乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)

圓兒圈圈發表於2019-03-04

目錄

  • Tabable是什麼?
  • Tabable 用法
  • 進階一下
  • Tabable的其他方法
  • webpack流程
  • 總結
  • 實戰!寫一個外掛

Webpack可以將其理解是一種基於事件流的程式設計範例,一個外掛合集。

而將這些外掛控制在webapck事件流上的執行的就是webpack自己寫的基礎類Tapable

Tapable暴露出掛載plugin的方法,使我們能 將plugin控制在webapack事件流上執行(如下圖)。後面我們將看到核心的物件 CompilerCompilation等都是繼承於Tabable類。(如下圖所示)

乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)

Tabable是什麼?

tapable庫暴露了很多Hook(鉤子)類,為外掛提供掛載的鉤子。

const {
	SyncHook,
	SyncBailHook,
	SyncWaterfallHook,
	SyncLoopHook,
	AsyncParallelHook,
	AsyncParallelBailHook,
	AsyncSeriesHook,
	AsyncSeriesBailHook,
	AsyncSeriesWaterfallHook
 } = require("tapable");
複製程式碼

乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)

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)
複製程式碼

乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)

  • 舉個例子
    • 定義一個Car方法,在內部hooks上新建鉤子。分別是同步鉤子 accelerate、break(accelerate接受一個引數)、非同步鉤子calculateRoutes
    • 使用鉤子對應的繫結和執行方法
    • calculateRoutes使用tapPromise可以返回一個promise物件。
//引入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流程圖,再慢慢分析~

乾貨!擼一個webpack外掛(內含tapable詳解+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);
	}
}

複製程式碼

process

entryOption

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;
});
複製程式碼

DllPlugin

其實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中多餘的註釋

乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)

怎麼寫一個外掛?

參照webpack官方教程Writing a Plugin

一個webpack plugin由一下幾個步驟組成:

  1. 一個JavaScript類函式。
  2. 在函式原型 (prototype)中定義一個注入compiler物件的apply方法。
  3. apply函式中通過compiler插入指定的事件鉤子,在鉤子回撥中拿到compilation物件
  4. 使用compilation操縱修改webapack內部例項資料。
  5. 非同步外掛,資料處理完後使用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中會返回很多內部物件,不完全截圖如下所示:

乾貨!擼一個webpack外掛(內含tapable詳解+webpack流程)

其中,我們需要的是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()
    ]
}
複製程式碼

外掛地址

參考資料

相關文章