likes
comments
collection
share

前端插件化方案研究[1]——webpack 插件机制

作者站长头像
站长
· 阅读数 16

前端插件化

前言

市面上有像一些扩展性的工具库,例如 webpack、babel、vite、rollup 等,它们支持插件化的方式帮助其扩展其功能。

也有如 vscode、atom、chromium 等项目也支持插件化的方法给用户提供更丰富的功能。

再浅一些,如 siyuan/blogfigma 等项目,它们在一些例如主题皮肤、图标等不非常侵占业务的定制化场景,提供给用户如 皮肤商城、市场 的功能,用户另一方面也可以自己开发想用的皮肤。

那么,这些插件化的实现原理是什么呢,内部又具备什么联系呢?笔者将深入学习研究一下。 ‍

为什么要插件化

笔者粗浅认为有这几个方面原因:

其一,当随着业务中定制化需求越来越多,如果还像之前那样通过 if else​ 实现,会导致代码变得难以维护,其中一种方案就是通过插件化,将一些定制化业务与基础业务解耦出来,方便项目扩展。

其二,项目核心维护者精力有限,难以将各个功能都考虑到位,借助开放插件市场可以帮助扩展其功能,例如 vscode 已经将 Auto rename Tag​、Bracket Pair colirizer​ 等常用插件内置

其三,插件就像项目的护城河,如果插件生态丰富,想要被替代是非常难的,这点可以用 webpack 举证,如今尽管 vite 具备了非常多优秀特性,但是也没有让 webpack 迅速淘汰掉,正是由于 webpack 的强大生态。

webpack 插件实现原理

webpack 作为前端非常重要的轮子之一,其插件化原理,是首先需要我们关注学习的。

由于 webpack 基本功能是支持以模块化的方式,将各种浏览器不支持的代码格式编译打包为浏览器支持的形式,从而方便前端工程化的推进,所以其插件化旨在扩展不同类型资源的打包编译。那么,仅从这点来看,它的插件结构会相对比较简单易懂,首先对其原理进行学习再合适不过了。

源码笔者看的是 v5.x 版本的 webpack,需要注意:

笔者在研究过程发现,有些博主提到 Compiler​ 和 Compilation​ 都是继承自 Tabpable 类的,也即可以通过 compiler.plugin 注册或者 apply 调用事件,这是因为 webpack v3 版本还没内置 hooks 对象集合,也就是 Compiler constructor 中没有 this.hooks 对象,所以在 v4 版本引入 hooks 对象时,为了兼容 v3,也支持 compiler.plugin、apply 的方法去分发事件。但是 v5 版本 webpack 已经完全舍弃了 v3 的写法,完全通过 hooks 属性监听、调用事件。因此,各位看官需要注意识别版本,避免引起不必要学习成本。

不同版本的 webpack 插件的使用方法不太一样,但是核心思想应该一样,所以在插件化的大方向下, 大家其实研究哪个大版本都是可以的。

如何开发 webpack 插件

先看如何实现一个 webpack 插件,了解其 api,如下是一个简单的插件demo​:

const pluginName = 'HelloWorldPlugin';

class HelloWorldPlugin {
  apply(compiler) {
    compiler.hooks.run.tap(pluginName, (compilation) => {
      console.log('Runing: Hello world!');
    });
  }
}

module.exports = HelloWorldPlugin;

其中 Plugin 类中的 apply 方法提供了 compiler 参数,通过这个参数我们监听 webpack 运行过程的生命周期钩子,也即官网所写的:

compiler.hooks.someHook.tap('MyPlugin', (params) => {
  /* ... */
});

原理分析

入口文件即 lib/webpack.js 中的 webpack 函数:

  1. 首先初始化 compiler 对象
const webpack = (
	() => {
		const create = () => {
			compiler = createCompiler(webpackOptions);
			return {compiler}
		}
		// 创建 compiler 对象
		const {compiler} = create();
	}
)

其中,createCompiler​ 方法主要是创建 compiler 对象,而且,在 webpack 函数执行过程中,只执行了一次 new Compiler ,也就是 compiler 对象是全局唯一的,随后并为各个 Plugin 设置好监听:

const createCompiler = rawOptions => {
	// 获取统一标准的 options
	const options = getNormalizedWebpackOptions(rawOptions);
	// new 一个 compiler 对象,这个对象是唯一的
	const compiler = new Compiler(options.context, options);
	// 逐一执行 plugin 的 apply 方法,对应上文 demo 中我们实现的 apply 方法
	if (Array.isArray(options.plugins)) {
		for (const plugin of options.plugins) {
			if (typeof plugin === "function") {
				plugin.call(compiler, compiler);
			} else {
				plugin.apply(compiler);
			}
		}
	}
	// 此处 compiler 已经开始调用 hooks 了,所以上面刚注册的 plugin 就会被调用了
	compiler.hooks.environment.call();
	compiler.hooks.afterEnvironment.call();
	compiler.hooks.initialize.call();

	return compiler;
};

而由上面代码我们可以看出,我们在 webpack 配置中的 plugin,会紧挨着 compiler 对象创建完便开始监听 webpack 的钩子,那么 Compiler 类中 constructor 可能已经初始化了一些钩子,打开 compiler 代码查看,果不其然:

const {
	SyncHook,
	SyncBailHook,
	AsyncParallelHook,
	AsyncSeriesHook
} = require("tapable");

class Compiler { 
	constructor(context, options = /** @type {WebpackOptions} */ ({})) {
		this.hooks = Object.freeze({
			/** @type {SyncHook<[]>} */
			initialize: new SyncHook([]),
			/** @type {SyncBailHook<[Compilation], boolean>} */
			shouldEmit: new SyncBailHook(["compilation"]),
			/** @type {AsyncSeriesHook<[Stats]>} */
			done: new AsyncSeriesHook(["stats"]),
			/** @type {AsyncSeriesHook<[Compiler]>} */
			beforeRun: new AsyncSeriesHook(["compiler"]),

			/** @type {AsyncSeriesHook<[Compiler]>} */
			run: new AsyncSeriesHook(["compiler"]),
			/** @type {AsyncSeriesHook<[Compilation]>} */
			emit: new AsyncSeriesHook(["compilation"]),
			/** @type {AsyncSeriesHook<[Compilation]>} */
			afterEmit: new AsyncSeriesHook(["compilation"]),
			// 剩下的 hook 定义此处省略,详细参考 webpack 的源码.....
		});

		this.webpack = webpack;
		// 初始化一些属性,此处省略....
	}
}


众所周知,webpack 核心事件机制是基于 tapable​ 的,此处的 SyncHookAsyncSeriesHookSyncBailHook 也都出自 tapable。tapable hook 使用时,先 new 一个 hook,例如 new SyncHook() 得到一个 hook 对象,就能利用 hook 的方法进行事件监听、调用:

  • hook.tap 绑定进行事件监听(其他类型可以异步调用 tapAsync)
  • hook.call 进行事件的调用(其他类型可以异步调用 callAsync)

简单起见,我们就只看 SyncHook 的源码:

function SyncHook(args = [], name = undefined) {
	const hook = new Hook(args, name);
	// 设置触发器
	hook.compile = COMPILE;
	//调用报错,省略...
	return hook;
}

// call 的代理函数
const CALL_DELEGATE = function(...args) {
	this.call = this._createCall("sync");
	return this.call(...args);
};

class Hook {
	constructor() {
		this.taps = [];
		this.call = CALL_DELEGATE;
	}
	_tap(type, options, fn) {
		options = Object.assign({ type, fn }, options);

		this._insert(options);
	}
	tap(options, fn) {
		this._tap("sync", options, fn);
	}
	// 核心代码,就是 insert 
	_insert(item) {
		//...省略
		this.taps[i] = item;
	}
	_createCall(type) {
		// 统一给后续集成 Hook 的 Hook 提供自定义 compile 函数的方法
		return this.compile({
			taps: this.taps,
			interceptors: this.interceptors,
			args: this._args,
			type: type
		});
	}
}

其实不难看出, Hook 的源码就是一个发布订阅的模式,但是具备一些特点:

  • 可以设置 tap 后的执行顺序,具体可以通过options 的 before 和 stage 属性
  • call 的实现相比普通的发布订阅模式要更复杂,其在调用过程中会通过 new Function(this.args(), code)​ 动态生成 call 函数体,具体实现步骤可以参考这位大佬的文章
  1. 进入 compiler.run 正式启动 webpack 的打包流程
compiler.run((err, stats) => {
	compiler.close(err2 => {
		callback(err || err2, stats);
	});
});

其中 run 的实现中,webpack 将钩子函数放置在特定的流程节点中调用,如下:

class Compiler {
	run(callback) {
	const run = () => {
		this.hooks.beforeRun.callAsync(this, err => {
			if (err) return finalCallback(err);

			this.hooks.run.callAsync(this, err => {
				if (err) return finalCallback(err);

				this.readRecords(err => {
					if (err) return finalCallback(err);

					this.compile(onCompiled);
				});
			});
		});
	};
	}
}

最后调用的 this.compile(onCompiled) 函数中也是类似的,也就是说,webpack 将 Compiler 类 constructor 中声明的 hook 分散在 compiler 的各个地方调用(执行 someHook.call),并传入 this 进入(插件开发时就是我们的 compiler 对象)给插件开发侧,这样插件中便可以访问到 webpack 编译打包过程的信息,并监听各个流程做相对应的操作。

tapable 不同类型 hook 的辨析

tapable 中不同类型 hook 的特点

我们已经知道 tapable 是基于发布订阅模式实现的,而 webpack 就使用 tapable 中的以下四种 hook,

	SyncHook,
	SyncBailHook,
	AsyncParallelHook,
	AsyncSeriesHook

那么这四种 hook 各自有什么特点呢?具体可以参考一下19组清风探究的结果,如下,这里不再赘述。

他将 tapable 的 hook 分为了如下几个种类:

  • Basic Hook:基本类型
  • Waterfall:瀑布类型
  • Bail:保险类型
  • Loop:循环类型

但是对于其研究结果,笔者这里再补充一下 parallel​ 也就是并行类型下的示意图,与之对应的是 series​ 串行工作机制。

前端插件化方案研究[1]——webpack 插件机制

总结下来就是 tapable 不仅提供了同步、异步的执行,也提供了 bail 这样的保险机制,还提供了 parallel 的并发执行机制,这为 webpack 的事件流管理提供了强大的底层支撑。

那么通过上述的介绍,对于 webpack 用的 SyncHook、SyncBailHook、AsyncParallelHook、AsyncSeriesHook hook,便不难推测其用法了。

但是在笔者学习中,碰到了以下两点问题,需要各位注意:

  • hook tap 过程中如果要传参,那么需要在 new Hook 时增加占位符。例如下面代码,需要创建 AsyncSeriesHook​ 时,添加 name​ 的占位符,这样 tap 时才能拿到 name 属性。
const { AsyncSeriesHook } = require('tapable');

const myFirstHook = new AsyncSeriesHook(['name'])

myFirstHook.tap('jack', (name) => {
    console.log('jack :>> hello', name);
})

myFirstHook.callAsync('sun', (err) => {
    console.log('every one say hello to sun');
});
  • Async 类 hook 的 tapAsync 需要在执行结束时执行 cb。如下,每次 tapAsync 结尾需要调用一次 cb,不然就保证不了最后 callAsync 中的回调能够执行。
const { AsyncSeriesHook } = require('tapable');

const myFirstHook = new AsyncSeriesHook(['name'])

myFirstHook.tapAsync('jack', (name, cb) => {
    setTimeout(() => {
	console.log('jack :>> hello', name);
	cb();
    }, 1000)
})

myFirstHook.tapAsync('mary', (name, cb) => {
    console.log('mary :>> hello', name);
    cb();
})

myFirstHook.callAsync('sun', (err) => {
    console.log('every one say hello to sun');
});

基于 tapable 实现一个插件系统 demo

参考 webpack 的插件化实现思路,我们也比葫芦画瓢基于 tapable 实现一个简易的插件系统。

背景为一个借书流程,模拟有一个人,他需要拥有借书卡,才能去借书中心,借、还书。

那么我们的目标便是,如何参考 webpack 的插件系统实现这个借书系统,利用插件实现整个流程可扩展。

具体实现,我们首先实现一个 Borrower 的 core 类:

// lib
function depu(arr) {
    return Array.from(new Set(arr));
}

function deleteArr(arr, toDelete) {
    return arr.filter(item => !toDelete.includes(item));
}

// core 类
class Borrower {
    context = {
        // 需要借的图书
        toBorrows: [],
        // 需要归还的图书
        toReturns: [],
        // 借书卡号码
        cardNum: null,
        // 借书人姓名
        name: '',

    }
    hooks = {};
    constructor(context) {
        this.context = {
            ...this.context,
            ...context,
        }
	// 定义几个 hooks
        this.hooks = {
	    // 注册借书卡
            registerCard: new AsyncSeriesHook(['context']),
            // 借书前
	    beforeBorrowBook: new SyncHook(['context']),
            // 借书
            borrowBook: new AsyncSeriesHook(['context']),
	    // 借书后
            afterBorrowBook: new SyncHook(['context']),
            // 还书
            returnBook: new AsyncSeriesHook(['context']),
        };
    }
    run() {
        this.hooks.registerCard.callAsync(this.context, () => {
            if (this.context.cardNum !== null) {
                this.hooks.beforeBorrowBook.call(this.context);
                this.hooks.borrowBook.callAsync(this.context, () => {
                    this.hooks.afterBorrowBook.call(this.context);
                })
                this.hooks.returnBook.callAsync(this.context, () => {
                })
            }
        });
    }

    setNum(num) {
        this.context.cardNum = num;
    }

    addToBorrows(books) {
        this.context.toBorrows = depu(this.context.toBorrows.concat(books));
    }

    deleteToBorrows(books) {
        this.context.toBorrows = deleteArr(this.context.toBorrows, books);
    }

    addToReturns(books) {
        this.context.toReturns = depu(this.context.toReturns.concat(books));
    }

    deleteToReturns(books) {
        this.context.toReturns = deleteArr(this.context.toReturns, books);
    }
}

function createBorrower(options) {
    const borrower = new BorrowBooksStudent(options.context);
    options.plugins.forEach((plugin) => {
        plugin.apply(borrower)
    })
    return borrower;
}

然后,我们将核心的借书卡注册中心、借书中心以插件形式提供:

const {v4} = require('uuid');

/* 卡务中心,借书之前需要先注册 */
class CardCenterPlugin {
    nameIdMaps = new Map()
    apply(borrower) {
        borrower.hooks.registerCard.tapAsync("CardCenterPlugin", (context, cb) => {
            if (!this.nameIdMaps.get(context.name)) {
                this.register(context.name);
            }
            borrower.setNum(this.nameIdMaps.get(context.name));
            cb();
        })
    }
    register(name) {
        this.nameIdMaps.set(name, v4())
        console.log(`【卡务中心】:你好 :>> ${name}, 欢迎注册,你的借书卡号为${this.nameIdMaps.get(name)}`);
    }
}

/* 借书中心 */
class BookCenterPlugin {
    leftBooks = [
        '三体',
        '红楼梦',
        '水浒传',
    ];
    apply(borrower) {
        borrower.hooks.beforeBorrowBook.tap("BookCenterPlugin", () => {
            console.log('【借书中心】当前借书中心还有这些书 :>> ', this.leftBooks);
        })
        borrower.hooks.borrowBook.tapAsync("BookCenterPlugin", (context, cb) => {
            const canBorrows = context.toBorrows.filter(b => this.leftBooks.includes(b))
            if (canBorrows.length) {
                console.log(`【借书中心】你好,现在你只能借这些书:${canBorrows.join()}`)
                this.deleteLeftedBooks(canBorrows);
                borrower.deleteToBorrows(canBorrows);
            }
            cb();
        });
        borrower.hooks.returnBook.tapAsync("BookCenterPlugin", (context, cb) => {
            this.addLeftedBooks(context.toReturns);
            borrower.deleteToReturns(context.toReturns);
            cb();
        })
    }

    addLeftedBooks(books) {
        this.leftBooks = depu(this.leftBooks.concat(books));
    }

    deleteLeftedBooks(books) {
        this.leftBooks = deleteArr(this.leftBooks, books);
        console.log(`【借书中心】现在剩下:${this.leftBooks.join()}`)
    }
}

最后,我们将所有插件注册,并运行起来:

/* 调用侧 */
createBorrower({
    context: {
        name: 'jack',
        toBorrows: ['三国演义', '水浒传'],
        toReturns: ['百年孤独'],
    },
    plugins: [
        new CardCenterPlugin(),
        new BookCenterPlugin(),
    ],
}).run();

运行结果是:

【卡务中心】:你好 :>> jack, 欢迎注册,你的借书卡号为9bc449a8-027d-41e1-8697-fa7892079f1d 【借书中心】当前借书中心还有这些书 :>> [ '三体', '红楼梦', '水浒传' ] 【借书中心】你好,现在你只能借这些书:水浒传 【借书中心】现在剩下:三体,红楼梦

如果有个同学想要让你帮他借书,那么我们只用新建一个 ClassmatePlugin​ 的插件,监听 Borrower 提供的钩子:

// 借书前某个同学想让帮忙借书
class ClassmatePlugin {
    toBorrows = [];
    constructor(toBorrows) {
        this.toBorrows = toBorrows;
    }
    apply(borrower) {
        borrower.hooks.beforeBorrowBook.tap("ClassmatePlugin", () => {
            borrower.addToBorrows(this.toBorrows);
        });

        borrower.hooks.afterBorrowBook.tap("ClassmatePlugin", (context) => {
            // 之前要借的书
            const beforeToBorrows = this.toBorrows.slice();
            // 差值即为本次帮助其借的书
            const borrowed = deleteArr(this.toBorrows, context.toBorrows);
            // 还要借的书
            this.toBorrows = deleteArr(beforeToBorrows, borrowed);
            if (borrowed.length) {
                console.log(`【同学】谢谢你,帮我借来了${borrowed.join()}`);
            }
        });
    }
}

运行结果:

【卡务中心】:你好 :>> jack, 欢迎注册,你的借书卡号为418f114b-105d-4a49-87ba-8c58a4b0d6f1 【借书中心】当前借书中心还有这些书 :>> [ '三体', '红楼梦', '水浒传' ] 【借书中心】你好,现在你只能借这些书:水浒传,三体 【借书中心】现在剩下:红楼梦 【同学】谢谢你,帮我借来了三体

总结

为了探究业界前端插件化的实现方案,笔者对 webpack 插件系统的实现进行深入研究,结果发现 webpack 主要基于 tapable 发布订阅来帮助其管理各个打包流程,提供众多基于 tapable 实现的 hooks 提供给插件,让插件能够介入 webpack 的打包流程中,保证其高扩展性。

tapable 支持异步、同步、并行、串行、保险等模式,具备非常出色的事件管理能力,笔者基于 tapable,按照 webpack 的思路,模仿实现了一个插件化的借书系统,体验了一把插件化的开发思路。

总之,webpack 的这种插件机制比较适合流程系统,而且未来业务方非常有可能需要添加逻辑介入这个流程。如果按照之前的思路,我们就按部就班实现这个基础流程,然后再 if else 判断增加哪些额外的逻辑,但是随着个性化需求越来越多,代码将越来越难以维护,但是如果使用 webpack 这样的插件化方案,可以保证个性化需求满足的同时,保证高扩展性。

笔者看来,软件开发没有银弹,普通简单的业务如果用一般方法就可以实现,就大可不必用插件化的方案绕来绕去,增加开发难度。

相关知识点:

webpack 和 tapable 中有很多值得探究的地方,例如:

  • Compiler、Compilation 辨析
  • tapable 动态生产代码的实现思路是什么
  • webpack 依赖寻找算法等等...

本文只是站在插件的角度去学习的,后续可以继续寻找其他角度进行探究。

参考