前端进阶:跟着开源项目学习插件化架构

10,260 阅读9分钟

本文在介绍微内核架构相关概念之后,阿宝哥将带大家从🍉视频播放入手,一步步分析微内核(插件化)架构设计三部曲。

一、微内核架构简介

1. 1 微内核的概念

微内核架构(Microkernel Architecture),有时也被称为插件化架构(Plug-in Architecture),是一种面向功能进行拆分的可扩展性架构,通常用于实现基于产品的应用。微内核架构模式允许你将其他应用程序功能作为插件添加到核心应用程序,从而提供可扩展性以及功能分离和隔离。

微内核架构模式包括两种类型的架构组件:核心系统(Core System)和插件模块(Plug-in modules)。应用逻辑被分割为独立的插件模块和核心系统,提供了可扩展性、灵活性、功能隔离和自定义处理逻辑的特性。

图中 Core System 的功能相对稳定,不会因为业务功能扩展而不断修改,而插件模块是可以根据实际业务功能的需要不断地调整或扩展。微内核架构的本质就是将可能需要不断变化的部分封装在插件中,从而达到快速灵活扩展的目的,而又不影响整体系统的稳定。

微内核架构的核心系统通常提供系统运行所需的最小功能集。许多操作系统使用的就是微内核架构,这也是它名字的由来。从商业应用程序的角度来看,核心系统一般是通用业务逻辑,没有特殊情况、特殊规则或复杂情形下的自定义代码。

插件模块是独立的模块,包含特定的处理、额外的功能和自定义代码,来向核心系统增强或扩展额外的业务能力。通常插件模块之间也是独立的,也有一些插件是依赖于若干其它插件的。重要的是,尽量减少插件之间的通信以避免依赖的问题。

1.2 微内核架构的优点

  • 灵活性高:整体灵活性是对环境变化快速响应的能力。由于插件之间的低耦合,改变通常是隔离的,可以快速实现。通常,核心系统是稳定且快速的,具有一定的健壮性,几乎不需要修改。
  • 可测试性:插件可以独立测试,也很容易被模拟,不需修改核心系统就可以演示或构建新特性的原型。
  • 性能高:虽然微内核架构本身不会使应用高性能,但通常使用微内核架构构建的应用性能都还不错,因为可以自定义或者裁剪掉不需要的功能。

介绍完微内核架构相关的基础知识,接下来我们将以西瓜视频播放器为例,分析一下微内核架构在西瓜视频播放器中的应用。

阅读阿宝哥近期热门文章(感谢掘友的鼓励与支持🌹🌹🌹):

二、西瓜视频播放器简介

西瓜视频播放器一款带解析器、能节省流量的 HTML5 视频播放器。它从底层解析 MP4、HLS、FLV 探索更大的视频播放可控空间。

(图片来源 —— http://h5player.bytedance.com/)

它的功能特色是从底层解析 MP4、HLS、FLV 探索更大的视频播放可控控件并拥有以下特点:

  1. 易扩展:灵活的插件体系、PC/移动端自动切换、安全的白名单机制;

  2. 更丰富:强大的 MP4 控制、点播的无缝切换、有效的带宽节省;

  3. 较完整:完整的产品机制、错误的监控上报、自动的降级处理。

上手西瓜视频播放器只需三步:安装、DOM 占位、实例化即可完成播放器的使用。

xgplayer-quick-start

(图片来源 —— pingan8787)

西瓜视频播放器主张一切设计都是插件,小到一个播放按钮大到一项直播功能支持。 想更好的自定义播放器完成自己业务的契合,理解插件机制是非常重要的,播放器本身有很多内置插件,比如报错、loading、重播等,如果大家想自定义效果可以关闭内置插件,自己开发即可。

默认情况下插件是自启动的,如果自定义插件不想自启动或者不想改变播放器默认的执行机制,建议以继承播放器类的方式开发。为了实现 "一切设计都是插件" 的主张,西瓜视频播放器团队采用了微内核的架构,下面我们开始来分析一下西瓜视频播放器的微内核实践。

三、西瓜视频播放器微内核实践

微内核架构模式包括两种类型的架构组件:核心系统和插件模块。在西瓜视频播放器中核心系统是由 Player 类来实现,该类对应的 UML 图如下所示:

(https://github.com/bytedance/xgplayer/blob/master/packages/xgplayer/src/player.js)

而插件模块主要就是西瓜视频播放器中的各种内置插件,比如控制条的音量控制组件、播放器贴图、播放器画中画和播放器下载控件等,除了上面提到的插件之外,目前西瓜视频播放器总共提供了 22 个插件,完整的内置插件如下图所示:

(西瓜视频播放器内置插件)

对于微内核的核心系统设计来说,它涉及三个关键技术:插件管理、插件连接和插件通信。下面我们将围绕这三个关键点来逐步分析西瓜视频播放器是如何实现的。

3.1 插件管理

核心系统需要知道当前有哪些插件可用,如何加载这些插件,什么时候加载插件。常见的实现方法是插件注册表机制。核心系统提供插件注册表(可以是配置文件,也可以是代码,还可以是数据库),插件注册表含有每个插件模块的信息,包括它的名字、位置、加载时机(启动就加载,或是按需加载)等。

在分析西瓜视频播放器插件管理机制前,我们先来看一下 xgplayer/packages/xgplayer/src 目录结构:

├── control
│   ├── collect.js
│   ├── cssFullscreen.js
│   ├── danmu.js
│   ├── ....
│   └── volume.js
├── error.js
├── index.js
├── player.js
├── proxy.js
├── style
│   ├── index.scss
│   ├── ...
│   └── variable.scss
└── utils
    ├── animation.js
    ├── database.js
    ├── ...
    └── util.js

通过观察以上目录结构,我们可以发现西瓜视频播放器的插件都统一存放在 control 目录下。那么现在问题来了,这些插件是如何被加载的?什么时候被加载?要回答这个问题,我们从该项目的入口出发:

// packages/xgplayer/src/index.js
import Player from './player' // ①
import * as Controls from './control/*.js' // ②
import './style/index.scss' // ③
export default Player // ④

index.js 文件中,我们发现在第二行代码中使用了 import * as Controls from './control/*.js' 语句批量导入播放器的所有内置插件。该功能是借助 babel-plugin-bulk-import 这个插件来实现的。

除了使用上述插件之外,还可以借助 Webpack context API 来实现,通过执行 require.context 函数获取一个特定的上下文,就可以实现自动化导入模块。在前端工程中,如果遇到从一个文件夹引入很多模块的情况,可以使用这个 API,它会遍历文件夹中的指定文件,然后自动导入模块,而不需要每次显式的调用 import 导入模块。

Webpack context API 的使用示例如下:

const contextRequire = require.context("./modules", true);

const modules = [];
contextRequire.keys().forEach((filename) => {
  if (filename.match(/^\.\/[^_][\w/]*\.([tj])s$/)) {
    modules.push(contextRequire(filename));
  }
});

好的,回到正题。现在我们已经知道西瓜视频播放器的所有内置插件,都是通过 babel-plugin-bulk-import 这个插件在构建阶段完成加载的。如果不想使用播放器中的内置控件,可以通过ignores 配置项关闭,使用自己开发的相同功能插件进行替换:

new Player({
  el:document.querySelector('#mse'),
  url: 'video_url',
  ignores: ['replay'] // 默认值[]
});

下个环节,我们来分析西瓜视频播放器的内置插件是如何连接到核心系统的。

3.2 插件连接

插件连接是指插件如何连接到核心系统。通常来说,核心系统必须指定插件和核心系统的连接规范,然后插件按照规范实现,核心系统按照规范加载即可。

要了解西瓜视频内置插件是如何连接到核心系统,我就需要来分析已有的内置的插件,这里我们以简单的 loading 内置插件为例:

// packages/xgplayer/src/control/loading.js
import Player from '../player'

let loading = function () {
  let player = this; 
  let util = Player.util; 
  let container = util.createDom('xg-loading', `
    <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewbox="0 0 100 100">
      <path d="M100,50A50,50,0,1,1,50,0"></path>
    </svg>
    `, {}, 'xgplayer-loading')
  player.root.appendChild(container)
}

Player.install('loading', loading)

(https://github.com/bytedance/xgplayer/blob/master/packages/xgplayer/src/control/loading.js)

在以上代码中,最重要的是最后一行,即 Player.install('loading', loading) 这一行。顾名思义,install 方法是用来安装插件其具体实现如下:

// packages/xgplayer/src/player.js
class Player extends Proxy {  
  static install (name, descriptor) {
    if (!Player.plugins) {
      Player.plugins = {}
    }
    Player.plugins[name] = descriptor
  }
}

通过观察以上代码可知,install 方法支持两个参数 namedescriptor,分别表示插件名称和插件描述器。当调用 Player.install 方法后,会把插件信息注册到 Player 类的 plugins 命名空间下。需要注意的是,这里仅仅是完成插件的注册操作。在利用 Player 类创建播放器实例的时候,才会进行插件初始化操作,代码如下:

class Player extends Proxy {
  constructor(options) {
    if (
      this.config.controlStyle &&
      util.typeOf(this.config.controlStyle) === "String"
    ) {
      // ...
      // 从服务器成功获取配置信息后,
      // 再调用self.pluginsCall()
    } else {
      this.pluginsCall();
    }
  }
}

Player 类构造函数中会调用 pluginsCall 方法来初始化插件,其中 pluginsCall 方法的具体实现如下:

class Player extends Proxy {
   pluginsCall() {
    let self = this;
    if (Player.plugins) {
      let ignores = this.config.ignores;
      Object.keys(Player.plugins).forEach(name => {
        let descriptor = Player.plugins[name];
        // 忽略ignores配置项关闭的插件
        if (!ignores.some(item => name === item)) {
          if (["pc", "tablet", "mobile"].some(type => type === name)) {
            if (name === sniffer.device) {
              setTimeout(() => {
                descriptor.call(self, self);
              }, 0);
            }
          } else {
            descriptor.call(this, this);
          }
        }
      });
    }
  }
}

了解完上述知识,我们再来介绍一下如何自定义西瓜视频播放器插件。在西瓜视频播放器中,自定义插件只有两个步骤:

1. 开发插件

// pluginName.js
import Player from 'xgplayer';

let pluginName=function(player){
  // 插件逻辑
}

Player.install('pluginName',pluginName);

2. 使用插件

import Player from 'xgplayer';

let player = new Player({
  id: 'xg',
  url: '//abc.com/**/*.mp4'
})

好的,我们继续进入下一个环节,即分析西瓜视频播放器核心系统和插件模块之间是如何通信的。

3.3 插件通信

插件通信是指插件间的通信。虽然设计的时候插件间是完全解耦的,但实际业务运行过程中,必然会出现某个业务流程需要多个插件协作,这就要求两个插件间进行通信;由于插件之间没有直接联系,通信必须通过核心系统,因此核心系统需要提供插件通信机制

这种情况和计算机类似,计算机的 CPU、硬盘、内存、网卡是独立设计的配置,但计算机运行过程中,CPU 和内存、内存和硬盘肯定是有通信的,计算机通过主板上的总线提供了这些组件之间的通信功能。

同样,我们以西瓜视频播放器的内置插件为切入点来分析插件通信机制,下面我们以 poster 内置插件为例。poster 插件用于设置播放器的封面图,该图是当播放器初始化后在用户点击播放按钮前显示的图像。

该插件的使用方式如下:

new Player({
  el:document.querySelector('#mse'),
  url: 'video_url',
  poster: '//abc.com/**/*.png' // 默认值""
});

该插件的对应源码如下:

import Player from '../player'

let poster = function () {
  let player = this; 
  let util = Player.util
  let poster = util.createDom('xg-poster', '', {}, 'xgplayer-poster');
  let root = player.root
  if (player.config.poster) {
    poster.style.backgroundImage = `url(${player.config.poster})`
    root.appendChild(poster)
  }

  // 监听播放事件,播放时隐藏封面图
  function playFunc () {
    poster.style.display = 'none'
  }
  player.on('play', playFunc)

  // 监听销毁事件,执行清理操作
  function destroyFunc () {
    player.off('play', playFunc)
    player.off('destroy', destroyFunc)
  }
  player.once('destroy', destroyFunc)
}

Player.install('poster', poster)

(https://github.com/bytedance/xgplayer/blob/master/packages/xgplayer/src/control/poster.js)

通过观察源码可知,该插件首先通过监听播放器的 play 事件来隐藏 poster 海报。此外还会监听播放器的 destory 事件来实现清理操作,比如移除 play 事件的监听器和 destroy 事件。

要实现上述功能,在源码中是通过 player 实例提供的 onoffonce 三个方法来实现,相信大多数读者对这三个方法都很熟悉了,它们分别用于实现添加监听(on)、移除监听(off)和单次监听(once)。

那么上述的三个方法来自哪里呢?通过阅读西瓜视频播放器的源码,我们发现上述方法是 Player 类通过继承 Proxy 类,在 Proxy 类中又通过构造继承的方式继承于来自 event-emitter 第三方库的 EventEmitter 类来实现的。

poster 插件中的监听了播放器的 playdestroy 事件,那这些事件是什么时候会触发呢?下面我们来分别分析一下:

1. play 事件

// packages/xgplayer/src/proxy.js
this.ev = ['play', 'playing', 'pause', 'ended', 'error', 'seeking', 
  'seeked','timeupdate', 'waiting', 'canplay', 'canplaythrough', 
  'durationchange', 'volumechange', 'loadeddata'].map((item) => {
     return {
       [item]: `on${item.charAt(0).toUpperCase()}${item.slice(1)}`
     }
});

this.ev.forEach(item => {
  self.evItem = Object.keys(item)[0]
  let name = Object.keys(item)[0]
  self.video.addEventListener(Object.keys(item)[0], function () {
     if (name === 'error') {
        if (self.video.error) {
          self.emit(name, new Errors('other', 
            self.currentTime, self.duration,
            self.networkState, self.readyState, 
            self.currentSrc, self.src,
            self.ended, {
                line: 41,
                msg: self.error,
                handle: 'Constructor'
              }))
          }
        } else {
          self.emit(name, self)
      }
});

(https://github.com/bytedance/xgplayer/blob/master/packages/xgplayer/src/proxy.js)

在西瓜视频播放器初始化的时候,会通过调用 Video 元素的 addEventListener 方法来监听各种原生事件,在对应的事件处理函数中,会调用 emit 方法进行事件派发。

2. destory 事件

// packages/xgplayer/src/player.js
function destroyFunc() {
  this.emit("destroy");
  // fix video destroy https://stackoverflow.com/questions/3258587/how-to-properly-unload-destroy-a-video-element
  this.video.removeAttribute("src"); // empty source
  this.video.load();
  if (isDelDom) {
    parentNode.removeChild(this.root);
  }
  for (let k in this) {
    delete this[k];
  }
  this.off("pause", destroyFunc);
}

(https://github.com/bytedance/xgplayer/blob/master/packages/xgplayer/src/player.js)

在西瓜视频播放器销毁时,会调用 destroyFunc 方法,在该方法内部,会继续调用 emit 方法来发射 destroy 事件。之后,若其它插件有监听 destroy 事件,那么将会触发对应的事件处理函数,执行相应的清理工作。而对于插件之间的通信,同样也可以借助 player 播放器对象上事件相关的 API 来实现,这里就不再展开。

前面我们已经从插件管理、插件连接和插件通信这三方面分析了西瓜视频播放器是如何实现微内核架构,下面我们用一张图来总结一下主要的内容:

四、总结

本文以西瓜视频播放器为例,详细介绍了微内核架构的设计要点与实现。其实西瓜视频播放器除了提供大量的内置插件之外,它也提供了一些功能插件,如 flv 和 hls 功能插件,从而来满足不同的播放场景。

此外,通过分析西瓜视频播放器,我们发现要设计一个功能完善的组件是很有挑战的一件事,要考虑非常多的事情,这里我以思维导图的形式简单整理了一下,有兴趣的读者可以参考一下。

想进一步了解西瓜视频播放器的读者,可以阅读我之前整理的 "西瓜视频播放器功能分析" 这篇文章。

(https://www.yuque.com/docs/share/a86a12a1-77c4-4f78-854b-af185f90bec4?#)

五、参考资源

本文使用 mdnice 排版