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

原文鏈接:https://www.jianshu.com/p/715...

目錄

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

Webpack可以將其理解是一種基於事件流的編程範例,一個插件合集。

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

Tapable暴露出掛載plugin的方法,使我們能 將plugin控制在webapack事件流上運行(如下圖)。後面我們將看到核心的對象 CompilerCompilation等都是繼承於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對象。
//引入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);
    }
}

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

<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由一下幾個步驟組成:

  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中會返回很多內部對象,不完全截圖如下所示:

<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()
    ]
}

插件地址

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章