如何设计一个插件系统

前言

前端的日常开发, 离不开众多由社区提供的第三方library, 从创建到项目打包再到代码的运行时统统都少不了它们的身影. 我们再使用他们的时候由于不同的业务需求,有时候需要对他们进行一些修改. 而设计优秀的的library通常都有一些方式对使用者提供扩展的能力, 这种提供扩展能力的方法通常叫做插件

说到插件大家应该都不陌生, 几乎再日常开发中能用到形形色色的插件, 比如webpack的plugin, babel的plugin, 或者是eslint的plugin, 又或者是vue的plugin,甚至是很多coder都在使用的vscode的extend等等, 数不胜数. 那么这些优秀的项目的扩展体系都是如何设计的? 他们又为什么这么设计? 我们能从这当中学习到什么? 接下来我将从一个简单的小功能作为例子,以一个library开发者的角度来考虑, 一个插件系统应该如何设计.

起步

一下是一个非常简单功能例子, 就叫他miniLibrary吧! 它的执行顺序十分简单: 输入数据 -> 数据处理 -> 渲染数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class DataProcessRender {
constructor() {
this.state = {};
}

process(a, b) {
return a + b;
}

apply(a, b, targetDom) {
this.state {
a,
b,
};
this.targetDom = targetDom;
this.render();
}

render() {
document.querySelector(this.state.targetDom).innerHtml = process(this.state.a, this.state.b);
}

}

调用的方法:

1
2
const render = new DateProcessRender()
render.apply('hello', ' world!!', '#app');

显然目前看来, 这个library不仅很简单, 而且根本没有任何扩展可言, 除了能够修改apply方法的参数以外, 我们什么也做不了, 那么如果我们想给他增加扩展能力,我们应该怎么办呢?

插件的引入

首先我们需要设计一个插件引用的方式, 插件的引入一般有两种方法, 一种是通过配置声明的方式引入, 比如webpack, babel都是这样的, 使用的是它们的config文件来引入:

1
2
3
{
plugin: ['upperCase-param', 'default-render'],
}

另一种是vue这种调用式的加载方式:

1
Vue.use(xxx)

实际上第一个和第二个者没有本质区别, 最后还是会已第二种的形式对插件进行引入,那么既然两者实际上都一样, 那么我们就需要自己去实现一个插件的加载器功能

插件加载器一般分成插件的加载和插件的调用两个部分, 插件的加载很简单, 无非就是一个方法把插件注入到Library的内部, 方便随时调用, 但是插件的调用确非诚的依赖插件系统的本身设计, 所以我们我们先看看插件系统通常都有哪些把!

常见插件系统分类

通常插件系统的分类有:

  • 事件钩子型
  • pipeline的流式调用型
  • 获取实例型

那么下面我们分别按三种方式来对我们的这个这个miniLibrary进行插件化吧!

事件钩子型

使用钩子插件系统的Library有很多, webpack babel都是

从代码上看, 程序执行有几个主要的运行阶段: apply process render 我们只需要在这几个地方增加生命周期函数, 就可以吧把扩展的能力给到外面了,而你需要定义的就是每个钩子函数能拿到什么参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class DataProcessRender {
constructor() {
this.state = {};
this.plugin = null
}

process(a, b) {
// 增加process钩子, 用于增加处理结果的扩展能力
return this.plugin?.process?.(a, b) || a + b;
}

apply(a, b, targetDom) {
// apply钩子, 可以扩展初始化数据
const [_a, _b, _targetDom] = this.plugin?.onApply?.(a, b, targetDom);
this.state {
a: _a,
b: _b,
};
this.targetDom = _targetDom;
this.render();
}

render() {
// runder钩子, 用于修改render的节点
const dom = this.plugin?.onRender?.(document.querySelector(this.state.targetDom), this.targetDom);
dom.innerHtml = process(this.state.a, this.state.b);
}

use(plugin) {
this.plugin = plugin;
}
}

调用的方法:

1
2
3
4
5
6
7
8
const plugin = {
onApply: (a ,b ,targetDom) => [a.toUpperCase(), b.toUpperCase(), targetDom];
onProcess: (a, b) => a + ' ' + b + ' some extend text';
onRender: (dom) => dom || document.querySelector('#defaultDom');
}
const render = new DateProcessRender()
render.use(plugin);
render.apply('hello', ' world!!', '#app'); // HELLO WORLD!! some extend text

以上这种是使通过不同的周期函数来把新的逻辑值入到对应的阶段, 再不同的阶段对数据做不同程度的修改, 这看上去是不是很像写react组件?实际上你也可以吧每一个react的component看成react的插件, 而React只是一个调用器

这种设计插件的方式属于典型的声明式的编程, 代码的可读性比较强, 很容易再阅读插件源码的时候搞清楚来龙去脉, 并且对插件的能力做出了有效的控制. 不过对插件能力的控制试一把双刃剑, 及减少了不良插件对library的影响, 也同时代表插件的扩展能力没有那么强大

pipeline的流式调用型

使用pipeline的方式来进行扩展, 典型的应用有webpack loader 和 express的中间件.

一次调用一个plugin, 以类似流水线的方式击鼓传花对数据进行操作, 最后对数据做出响应的扩展方案, 一个个关联看似不大的插件, 就这么组合出一个强大的功能

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class DataProcessRender {
constructor() {
this.state = {};
this.plugins = [];
}

apply(a, b, targetDom) {
this.state {
a,
b,
}
this.targetDom = targetDom;
run();
}
process() {
data.result = data.a + '' + data.b;
}

render() {
document.querySelector(this.state.targetDom).innerHtml(this.state.result)
}

run() {
this.process();
let index = 0;
const context = {
data: this.state;
targetDom: this.targetDom;
}
// 这里只支持了同步的调用
const next = () => plugins.length < index + 1 ? this.plugins[index](context, next);
// 依次调用插件, 进行数据处理
if (this.plugins.length) {
this.plugins[index](context, next);
}
this.render();
}

use(plugin) {
this.plugins.push(plugin);
}
}

调用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const pluginA = (context, next) =>  {
const { data } = context;
data.a = data.a.toUpperCase();
data.b = data.a.toUpperCase();
next()
}

const pluginB = (context, next) => {
const { data } = context;
data.result = data.a + ' ' + dat.b + ' some extend text';
next()
}

const pluginC = (context, next) => {
if (!document.querySelector(contest.targetDom)) {
context = '#defaultDom';
}
}

const render = new DateProcessRender()
// 注册
render.use(pluginA);
render.use(pluginB);
render.use(pluginC);
render.apply('hello', ' world!!', '#app'); // HELLO WORLD!! some extend text

上面故意多些几个例子是为了让每个插件的职责单一, 实际上这也着这类pipeline插件的特点, 比较容易写出职责单一的插件, 方便在后续的使用中进行有效的组合已经复用. 同时这种方式也能比较好的控制住插件的能力,你只需要把希望插件控制的属性传给他即可

获取实例型

获取实例的插件系统也有很多Library在使用, jQuery Vue
获取实例的方式, 可以让插件直接拿到Library的实例, 也就以为这他能拿到几乎所有的数据, 和直接改源码效果差不多, 非常的强大粗暴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class DataProcessRender {
constructor() {
this.state = {};
}

process(a, b) {
return a + b;
}

apply(a, b, targetDom) {
this.state {
a,
b,
};
this.targetDom = targetDom;
this.render();
}

render() {
document.querySelector(this.state.targetDom).innerHtml = process(this.state.a, this.state.b);
}

use(plugin) {
plugin(this);
}
}

调用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
const plugin(instance) {
instance.process = function(a, b) {
return a.toUpperCase() + ' ' + b.toUpperCase() + ' some extend text';
}
instance.render() {
const dom = document.querySelector(this.state.targetDom) || document.querySelector('#defaultDom')
dom.innerHtml = process(this.state.a, this.state.b);
}
}

const render = new DateProcessRender()
render.use(pluginA);
render.apply('hello', ' world!!', '#app'); // HELLO WORLD!! some extend text

这种方法显然是这三种当中最直接粗暴的策略, 给插件提供了非常非常强大的能力, 用这种方式, 你可以用很多种实现我们需要的功能, 但是强大的能力带给我们的是更多的风险. 我们需要去小心插件产生的副作用以及更陡峭的插件开发学习曲线. 因为你可以获得整个实例的所有数据. 你不仅仅可以读取实例所有数据, 你还可以给实例增加新的方法和属性, 但是这都需要又一个前提, 你对library的源码要了解的足够多, 以防你不小心覆盖了library的原生方法和属性导致了未知的错误

同时虽然有很好的扩展能力, 但是多个插件之间非常不容易相互配合, 并且插件的编写没什么解构调理, 让阅读代码上让人感到吃力

小结

首先得说明一下, 上面的例子都非常的简陋, 只是为了说明概念而设计的, 实际上的正规项目, 这些方案会复杂很多, 这里仅仅作为抛砖引玉.

那么问题来了, 以上三种插件模式到底哪一种好? 在我看来他们各有各的特点, 没有办法说出具体哪个好, 只能是各有千秋, 以及能力上的取舍而已, 实际上社区里的各大Library也并非是同时只在使用一个模式, 他们往往是相互使用的, 可能同时存在及使用钩子, 又使用继承, 既使用继承, 又使用pipeline. 比如webpack,他就是把这三种方式进行了融合, 感兴趣的同学可以看我之前的博文.

回到开头的问题上来, 我们能从这些插件系统模式上学到什么? 我觉得是让一个个看似黑箱的Library更加透明, 让写一个插件感觉并没有那么云里雾里, 让我们在写代码的时候能够知其然也能知其所以然.

银弹?

插件系统的设计有没有银弹呢? 我觉得没有, 但是会有一些很好的实践.

目前很多Library的插件系统做的都十分底层, 并不是简简单单中来做功能扩展的, 而是一个完整的运行架构. 所以的library内置功能都是基于插件来开发的, 比如babel-core 只是做代码到ast再到代码的转换, 大概是这样: (a) => a, 所有的语法转换都是基于一个个的插件来进行特定语法的转移
还有就是webpack的构建运行时, 从构建启动, 到文件生成, 都是一系列的声明周期钩子的调用.几乎95%的打包实现都是由不同的插件来实现的.

可以吧babel-core webpack-runtime 想象成我们开发使用的react, 所谓的Library功能, 就是我们写的react项目, 可以说功能插件化这是未来大型Library设计趋势,

结语

插件系统应该还有很多其他的方法论, 如果我在之后又看到新鲜的方案, 会第一时间分享出来.