插件化系统的实现

729 阅读4分钟

最近给一个播放器加了下插件系统,不知道后面维护会不会有坑,所以记录下来,顺便探讨一下。

设计接口时,可以先写一段期望用户调用的代码,然后基于调用方式进行开发。

对于插件而言,一般是先注册再使用,但每个插件作为单独的包发布的话,可以让插件自注册,从而减少一行调用代码,用户可以引入即用。先看一下调用方式:


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初始化参数,才能让用户在 自己的插件里 和 创建播放器时 得到正确的类型提示。

记录第一篇掘金博客~