我们常用的前端工具中,提供“插件”概念的不在少数,如WordPress、jQuery、Vue等。
作为库和框架中非常常见的特性之一,插件存在的意义是:它使得开发者能够通过一种安全的、可控的方式下进行功能的添加或扩展。这不仅能提升核心项目的价值,还创造了一个包容的社区。最重要的是!插件的存在并不会造成额外的维护负担。
那么,我们应该如何构建一个插件系统呢?让我们用JavaScript的方式来解答这个问题。
一个最简的计算器程序
我们先从一个名为“BetaCalc”的例子开始。BetaCalc的目标是成为一个最简的JavaScript计算器,这个计算器可以让其他开发者们往里增加“按钮”。以下为我们计算器最原始的代码:
// The Calculator
const betaCalc = {
currentValue: 0,
setValue(newValue) {
this.currentValue = newValue;
console.log(this.currentValue);
},
plus(addend) {
this.setValue(this.currentValue + addend);
},
minus(subtrahend) {
this.setValue(this.currentValue - subtrahend);
}
};
// Using the calculator
betaCalc.setValue(3); // => 3
betaCalc.plus(3); // => 6
betaCalc.minus(2); // => 4
我们为了简单起见,使用字面量对象定义了一个计算器。这个计算器通过console.log的方式将计算结果打印出来。
目前的功能非常有限,我们有一个setValue方法用于设置数值和展示它。此外我们还有plus和minus这两个方法,它们将对当前数值进行操作。
是时候添加更多的功能了,让我们从创建一个插件系统开始。
最小的插件系统
我们尝试增加一个register方法,开发者可以通过它来注册自己的插件。这个方法的工作非常简单:取得扩展插件信息,提取其中的exec函数,然后将这个函数附加到我们的计算器内作为他的新方法。以下为代码:
// The Calculator
const betaCalc = {
// ...other calculator code up here
register(plugin) {
const { name, exec } = plugin;
this[name] = exec;
}
};
如下为一个插件例子,这个插件为我们的计算器提供一个“乘方”按钮:
// Define the plugin
const squaredPlugin = {
name: 'squared',
exec: function() {
this.setValue(this.currentValue * this.currentValue);
}
};
// Register the plugin
betaCalc.register(squaredPlugin);
在许多的插件系统中,插件通常由两部分组成:
- Code——用于执行的代码;
- Metadata——元数据(包括名称、描述、版本号、依赖等等)。
在我们的插件中,exec函数包含了我们的代码,name则是我们的元数据。当插件被注册后,exec函数被作为方法直接绑定到betaCalc对象上,并且它可以访问BetaCalc的this。
所以现在BetaCalc已经拥有一个新的“乘方”按钮,它的调用方式如下:
betaCalc.setValue(3); // => 3
betaCalc.plus(2); // => 5
betaCalc.squared(); // => 25
betaCalc.suqared(); // => 625
这个系统有很多值得称道的地方。这个插件是一个简单的字面量对象,并且可以被传入我们的函数中。这意味着插件们可以被通过npm下载并作为ES6模块引入。轻松发布非常重要!
但是我们的系统仍有一些缺陷。
通过插件的方式授予BetaCalc的访问权限,他们可以读写BetaCalc的所有代码。虽然这样对于获取和设置currentValue非常有效,但这同样暗含危险。打个比方,如果一个插件重新定义了内置函数(如setValue),这可能会对BetaCalc和其他插件制造一些不可预知的后果。这违背了“开闭原则”(开闭原则认为软件实体应该对扩展开放,但对修改关闭)。
此外,“squared”函数运行的时候会产生副作用。虽然这在JavaScript世界中并不算罕见,但这并不能让人满意——尤其是当其他插件可能会搞乱共同的内部状态的时候。下面将介绍一种更函数式的方法,这种方法将大大有助于让我们的系统变得更安全和更可预测。
一个更完善的插件架构
让我们重构一个更好的插件架构。下面这个例子将重构整个计算器及其插件API:
// The Calculator
const betaCalc = {
currentValue: 0,
setValue(value) {
this.currentValue = value ;
console.log(this.currentValue) ;
},
core: {
'plus': (currentVal,addend) => currentVal + addend,
'minus': (currentVal,subtrahend) => currentVal - subtrahend,
},
plugins: {},
press(buttonName, newVal) {
const func = this.core[buttonName] || this.plugins[buttonName];
this.setValue(func(this.currentValue, newVal));
},
register(plugin) {
const { name,exec } = plugin;
this.plugins[name] = exec;
}
};
// Our Plugin
const squaredPlugin = {
name: 'squared',
exec: function(currentValue) {
return currentValue * currentValue;
}
};
betaCalc.register(squaredPlugin);
// using the calculator
betaCalc.setValue(3); // => 3
betaCalc.press('plus', 2); // => 5
betaCalc.press('squared'); // => 25
betaCalc.press('squared'); // => 625
有几个值得一提的改进在这。
首先,我们将插件(plugins)从核心方法(如plus和minus)集(core)里抽离,这个思路是通过将插件放在属于他们自己的plugins对象中来实现的。将插件存储在插件对象中使得我们的系统变得更安全。如今,当我们调用插件时,插件无法通过this来访问betaCalc的任何属性——插件通过this只能访问betaCalc.plugins这个对象。
其次,我们实现了一个press方法,这个方法可以通过插件名(name)来找到并调用插件。现在,当我们调用一个插件(plugin)并执行它的函数(function),我们将计算器的当前值(currentValue)传入并期望它返回新的计算结果。
实际上,这个新的press方法将我们所有的计算器按钮转换成了“纯函数”。这些纯函数接收一个值、执行操作、返回最终结果。这样一来带来以下的一些好处:
- 简化API
- 更易于测试(包括betaCalc本身及与其相关的插件)
- 缩减了系统的依赖,使其耦合度更低
与最初的例子相比,这个新的架构带来了更多的限制,但这是利大于弊的。我们实际上制定了一个标准,用于约束插件的开发者们必须按照我们能够预想的方式进行插件的开发。
实际上,这可能过于严格!现在我们的计算器插件只能围绕着currentValue进行运算。如果插件作者想要添加高级功能,例如“记忆”按钮或跟踪历史记录的方法,他们显然无法实现。
也许这样就可以了。插件系统给予插件开发者的自由度是个微妙的平衡。给予太多自由度会影响系统的稳定性。给予太少的自由度又会让插件开发难以解决它们的问题——在这种情况下,可能还不如不采用插件系统。
如何进一步改进?
有很多方式可以提升我们的插件系统。
我们可以增加异常处理机制,用于当插件作者忘记定义插件名或忘记返回值的时候提醒插件作者。这是一种很好的思路,像质量保证开发人员一样思考问题,想象我们的系统会如何奔溃,这样我们就能主动处理这些情况。
我们可以扩大插件的功能范围。目前,一个betaCalc插件可以增加一个“按键”。那它能不能考虑允许注册一些挂载在特定生命周期节点上的一些回调呢——例如计算器显示一个数值?或者如果有一个专门的地方来存储多个交互中存储的一段状态呢?这会不会带来一些新的使用场景?
我们还可以扩展插件注册功能。例如一个插件可以通过一些初始设置进行注册?这样能否让插件更灵活?如果插件作者希望注册一整套按钮集而不只是单一按钮——比如betaCalc统计包?需要进行怎样的改进才能支持这些扩展?
你自己的插件系统
例子中的betaCalc及其插件系统都刻意保持了简洁。如果你的项目比较庞大,那么你将会想要探索一些其他的插件架构。
一个好的起点是查看已有的项目以获取成功的插件系统的范例。在JavaScript世界中,可以是jQuery、Gatsby、CKEditor等等。
你或许还想熟悉各种JavaScript设计模式(sparkbox.com/foundry/jav…)。每种模式都提供不同的接口和耦合程度,这为我们提供了许多优秀的插件架构选型可供选择。了解这些选项可以帮助我们更好地平衡项目中不同角色(如系统开发者、插件开发者)的需求。
除了这些模式以外,也有很多优秀的软件开发原则可供我们参考。就像前面提到的开闭原则、松散耦合原则等,以及德米特定律和依赖注入等。
我知道这听起来很多,但你必须进行你的研究。没有什么比因为你修改插件架构而让每个人重写他们的插件更痛苦了。这是一种快速失去信任的方式,也会让人们对未来的贡献望而却步。
结语
从头开始写一个优秀的插件架构是非常困难的!你必须平衡很多考虑因素才能构建一个满足所有人需求的系统。足够简单吗?足够强大吗?它会长期有效吗?
不过,这些努力都是值得的。一个优秀的插件系统可以帮助到所有人。开发者们可以获得解决他们问题的自由、终端用户可以获得大量可选功能、你也可以围绕你的项目建立一个生态系统和社区。这是一个三赢的局面。
Designing a JavaScript Plugin System | CSS-Tricks Javascript Design Patterns: What They Are & How To Use Them Learning JavaScript Design Patterns