最近给一个播放器加了下插件系统,不知道后面维护会不会有坑,所以记录下来,顺便探讨一下。
设计接口时,可以先写一段期望用户调用的代码,然后基于调用方式进行开发。
对于插件而言,一般是先注册再使用,但每个插件作为单独的包发布的话,可以让插件自注册,从而减少一行调用代码,用户可以引入即用。先看一下调用方式:
import { H5Player } from 'xxplayer'
import { PluginTest } 'xxplayer-plugin-test'
// avoid hard-code name string
const player = new H5Player({ plugins: [PluginTest.pluginName] })
看起来还算优雅吧,下面说一下其中具体的设计。
1. 插件的存储
H5Player是一个已有的播放器,我在这之上新增了一个插件层,主要维护一个插件map,方便后面注册和使用。
而插件map其实是用一个局部对象来存放的,然后对这个对象提供读写函数,并在函数里做一些合法性校验。类似这样(为了简洁省略校验部分的代码):
const plugins: Record<string, Function> = {}
export const registerPlugin = (name, value) => plugins[name] = value
export const deregisterPlugin = (name, value) => delete plugins[name]
// hasPlugin getPlugin...
最后把读写函数作为静态属性挂载到原来的H5Player上,接下来就可以愉快的注册和使用插件了。
export class H5Player {
static registerPlugin = registerPlugin;
static deregisterPlugin = deregisterPlugin;
static getPlugins = getPlugins;
static getPlugin = getPlugin;
}
2. 插件基类
所有插件可以继承自一个Plugin基类,基类主要是对一些公共行为的收集和约束,比如销毁时移除事件监听和dom,减少初始化代码等。
import { EventEmitter } from 'eventemitter3';
export class Plugin extends EventEmitter {
static pluginName = '';
constructor(public player, _config?: any) {
super();
if (new.target === Plugin) {
throw new Error('Plugin must be sub-classed; not directly instantiated.');
}
player.on('destroy', this.destroy.bind(this));
}
destroy() {
this.player = null;
this.removeAllListeners();
}
}
不继承基类的话,也可以使用简单的函数插件,绑定其中的this到要操作的对象即可。
3. 重点-插件间通信
插件通信无非事件和上下文,事件用的过多则容易陷入泥潭,广播事件其实类似于goto,不利于代码调试和后续维护,所以一般要结合使用 & 借助其他方式来减少事件。下面说一下减少全局事件的几种方法。
1. 上下文
组件需要暴露给外部访问的变量可以挂载到全局上下文里,对于简单的插件化系统来说,其实用上下文就足够了,就像koa中间件的ctx。
2. 生命周期
通过规范化的事件来减少事件广播,同时方便用户理解。
插件的创建一般不包含异步逻辑,所以最少只需要两个事件就足够了,创建前 & 创建后。
从设计上来说,插件应该相互独立,理论上支持并行执行,但js不能并行,所以实际上还是串行的;注意要把每个插件的执行代码放在try-catch里,避免某个错误影响其他插件的执行。
3. Hook
参考tapable。
上面是常规的做法,然后说一下这次的不同之处。
借鉴于videojs的设计,我把插件实例挂载到了player实例对象上,让每个插件都可以访问到其他的插件的实例,从而可以进行一些动态的修改。
export class H5Player {
pluginInstances: Record<string, object> = {}
}
这样就不需要全局的事件中心,事件只在插件内部使用,可以在自己的事件里调用其他插件的方法,也可以在其他插件的事件里调用自己的方法,十分灵活。
举个例子
import { Plugin, IH5Player, IPlayerConfig } from 'xxplayer';
import { pluginB } from 'xxplayer-plugin-b';
export class pluginA extends Plugin {
constructor(public player: IH5Player, public config: IPlayerConfig) {
super(player, config);
const { pluginName } = pluginB;
const pluginb = player.pluginInstances[pluginName];
const addDynamicEvent = () => pluginb.on('event', this.onEvent)
// 如果知道插件执行顺序,可以更简单的调用
if (pluginb) {
addDynamicEvent()
} else {
player.on(`beforeplugincreate:${pluginName}`, addDynamicEvent)
}
}
// avoid bind this
onEvent = () => { }
}
写在最后
用ts写插件系统其实不太方便,每个插件都要扩展pluginInstances
对象和IPlayerConfig
初始化参数,才能让用户在 自己的插件里 和 创建播放器时 得到正确的类型提示。
记录第一篇掘金博客~