插件系统被广泛应用在各个系统中,比如浏览器、各个前端库如vue、webpack、babel,它们都可以让用户自行添加插件。插件系统的好处是允许开发人员在保证内部核心逻辑不变的情况下,以安全、可扩展的方式添加功能。
本文参考了webpack的插件,不引入任何库,写一个简单的插件系统,帮助大家理解插件化思维。
下面我们先看看插件有哪些概念和设计插件的流程。
准备
三个概念
- 核心系统(Core):有着系统的基本功能,这些功能不依赖于任何插件。
- 核心和插件之间的联系(Core <--> plugin):通过核心系统提供的钩子将二者关联一起。
- 插件(plugin):相互独立的模块,提供了单一的功能。
插件系统的设计和执行流程
那么对着上面三个概念,设计插件的流程:
- 首先要有一个核心系统。
- 然后确定核心系统的生命周期和暴露的 API。
- 最后设计插件的结构。
- 插件的注册 -- 安装加载插件到核心系统中。
- 插件的实现 -- 利用核心系统的生命周期钩子和暴露的 API。
最后代码执行的流程是:
- 注册插件 -- 绑定插件内的处理函数到生命周期
- 调用插件 -- 触发钩子,执行对应的处理函数
直接看代码或许更容易理解⬇️
代码实现
准备一个核心系统
一个简单的 JavaScript 计算器,可以做加、减操作。
js
class Calculator {
constructor(options = {}) {
const { initialValue = 0 } = options
this.currentValue = initialValue;
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
this.currentValue = value;
}
plus(addend) {
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.setValue(this.currentValue - subtrahend);
}
}
// test
const calculator = new Calculator()
calculator.plus(10);
calculator.getCurrentValue() // 10
calculator.minus(5);
calculator.getCurrentValue() // 5
确定核心系统的生命周期
实现Hooks
核心系统想要对外提供生命周期钩子,就需要一个事件机制。不妨叫Hooks。(日常开发可以考虑使用webpack的核心库 Tapable )
js
class Hooks {
constructor() {
this.listeners = {};
}
on(eventName, handler) {
let listeners = this.listeners[eventName];
if (!listeners) {
this.listeners[eventName] = listeners = [];
}
listeners.push(handler);
}
off(eventName, handler) {
const listeners = this.listeners[eventName];
if (listeners) {
this.listeners[eventName] = listeners.filter((l) => l !== handler);
}
}
trigger(eventName, ...args) {
const listeners = this.listeners[eventName];
const results = [];
if (listeners) {
for (const listener of listeners) {
const result = listener.call(null, ...args);
results.push(result);
}
}
return results;
}
destroy() {
this.listeners = {};
}
}
暴露生命周期(通过Hooks)
然后将hooks运用在核心系统中 -- JavaScript 计算器
每个钩子对应的事件:
- pressedPlus 做加法操作
- pressedMinus 做减法操作
- valueWillChanged 即将赋值currentValue,如果执行此钩子后返回值为false,则中断赋值。
- valueChanged 已经赋值currentValue
js
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0 } = options
this.currentValue = initialValue;
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
const result = this.hooks.trigger('valueWillChanged', value);
if (result.length !== 0 && result.some( _ => ! _ )) {
} else {
this.currentValue = value;
}
this.hooks.trigger('valueChanged', this.currentValue);
}
plus(addend) {
this.hooks.trigger('pressedPlus', this.currentValue, addend);
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.hooks.trigger('pressedMinus', this.currentValue, subtrahend);
this.setValue(this.currentValue - subtrahend);
}
}
设计插件的结构
插件注册
js
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options
this.currentValue = initialValue;
// 在options中取出plugins
// 通过plugin执行apply来注册插件 -- apply执行后会绑定(插件内的)处理函数到生命周期
plugins.forEach(plugin => plugin.apply(this.hooks));
}
...
}
插件实现
插件一定要实现apply方法。在Calculator的constructor调用时,才能确保插件"apply执行后会绑定(插件内的)处理函数到生命周期"。
apply的入参是this.hooks,通过this.hooks来监听生命周期并添加处理器。
下面实现一个日志插件和限制最大值插件:
js
// 日志插件:用console.log模拟下日志
class LogPlugins {
apply(hooks) {
hooks.on('pressedPlus',
(currentVal, addend) => console.log(`${currentVal} + ${addend}`));
hooks.on('pressedMinus',
(currentVal, subtrahend) => console.log(`${currentVal} - ${subtrahend}`));
hooks.on('valueChanged',
(currentVal) => console.log(`结果: ${currentVal}`));
}
}
// 限制最大值的插件:当计算结果大于100时,禁止赋值
class LimitPlugins {
apply(hooks) {
hooks.on('valueWillChanged', (newVal) => {
if (100 < newVal) {
console.log('result is too large')
return false;
}
return true
});
}
}
全部代码
js
class Hooks {
constructor() {
this.listener = {};
}
on(eventName, handler) {
if (!this.listener[eventName]) {
this.listener[eventName] = [];
}
this.listener[eventName].push(handler);
}
trigger(eventName, ...args) {
const handlers = this.listener[eventName];
const results = [];
if (handlers) {
for (const handler of handlers) {
const result = handler(...args);
results.push(result);
}
}
return results;
}
off(eventName, handler) {
const handlers = this.listener[eventName];
if (handlers) {
this.listener[eventName] = handlers.filter((cb) => cb !== handler);
}
}
destroy() {
this.listener = {};
}
}
class Calculator {
constructor(options = {}) {
this.hooks = new Hooks();
const { initialValue = 0, plugins = [] } = options;
this.currentValue = initialValue;
plugins.forEach((plugin) => plugin.apply(this.hooks));
}
getCurrentValue() {
return this.currentValue;
}
setValue(value) {
const result = this.hooks.trigger("valueWillChanged", value);
if (result.length !== 0 && result.some((_) => !_)) {
} else {
this.currentValue = value;
}
this.hooks.trigger("valueChanged", this.currentValue);
}
plus(addend) {
this.hooks.trigger("pressedPlus", this.currentValue, addend);
this.setValue(this.currentValue + addend);
}
minus(subtrahend) {
this.hooks.trigger("pressedMinus", this.currentValue, subtrahend);
this.setValue(this.currentValue - subtrahend);
}
}
class LogPlugins {
apply(hooks) {
hooks.on("pressedPlus", (currentVal, addend) =>
console.log(`${currentVal} + ${addend}`)
);
hooks.on("pressedMinus", (currentVal, subtrahend) =>
console.log(`${currentVal} - ${subtrahend}`)
);
hooks.on("valueChanged", (currentVal) =>
console.log(`结果: ${currentVal}`)
);
}
}
class LimitPlugins {
apply(hooks) {
hooks.on("valueWillChanged", (newVal) => {
if (100 < newVal) {
console.log("result is too large");
return false;
}
return true;
});
}
}
// run test
const calculator = new Calculator({
plugins: [new LogPlugins(), new LimitPlugins()],
});
calculator.plus(10);
calculator.minus(5);
calculator.plus(1000);
脚本的执行结果如下,大家也可以自行验证一下
看完代码可以回顾一下"插件系统的设计和执行流程"哈。
更多实现
假如要给Calculator设计一个扩展运算方式的插件,支持求平方、乘法、除法等操作,这时候怎么写?
实际上目前核心系统Calculator是不支持的,因为它并没有支持的钩子。那这下只能改造Calculator。
可以自行尝试一下怎么改造。也可以直接看答案:github.com/coder-xuwen...
最后
插件化的好处
在上文代码实现的过程中,可以感受到插件让Calculator变得更具扩展性。
- 核心系统(Core)只包含系统运行的最小功能,大大降低了核心代码的包体积。
- 插件(plugin)则是互相独立的模块,提供单一的功能,提高了内聚性,降低了系统内部耦合度。
- 每个插件可以单独开发,也支持了团队的并行开发。
- 另外,每个插件的功能不一样,也给用户提供了选择功能的能力。
本文的局限性
另外,本文的代码实现很简单,仅供大家理解,大家还可以继续完善:
- 增加ts类型,比如给把所有钩子的类型用emun记录起来
- 支持动态加载插件
- 提供异常拦截机制 -- 处理注册插件插件的情况
- 暴露接口、处理钩子返回的结构时要注意代码安全
参考
Designing a JavaScript Plugin System | CSS-Tricks