前端進階:跟着開源項目學習插件化架構

1、微內核架構簡介

1. 1 微內核的概念

微內核架構(Microkernel Architecture),有時也被稱爲插件化架構(Plug-in Architecture),是一種面向功能進行拆分的可擴展性架構,一般用於實現基於產品的應用。微內核架構模式容許你將其餘應用程序功能做爲插件添加到核心應用程序,從而提供可擴展性以及功能分離和隔離。javascript

微內核架構模式包括兩種類型的架構組件:核心系統(Core System)和插件模塊(Plug-in modules)。應用邏輯被分割爲獨立的插件模塊和核心繫統,提供了可擴展性、靈活性、功能隔離和自定義處理邏輯的特性。css

microkernel-architecture-pattern.png

圖中 Core System 的功能相對穩定,不會由於業務功能擴展而不斷修改,而插件模塊是能夠根據實際業務功能的須要不斷地調整或擴展。微內核架構的本質就是將可能須要不斷變化的部分封裝在插件中,從而達到快速靈活擴展的目的,而又不影響總體系統的穩定。html

微內核架構的核心繫統一般提供系統運行所需的最小功能集。許多操做系統使用的就是微內核架構,這也是它名字的由來。從商業應用程序的角度來看,核心系統通常是通用業務邏輯,沒有特殊狀況、特殊規則或複雜情形下的自定義代碼。前端

插件模塊是獨立的模塊,包含特定的處理、額外的功能和自定義代碼,來向核心系統加強或擴展額外的業務能力。一般插件模塊之間也是獨立的,也有一些插件是依賴於若干其它插件的。重要的是,儘可能減小插件之間的通訊以免依賴的問題。java

1.2 微內核架構的優勢

  • 靈活性高:總體靈活性是對環境變化快速響應的能力。因爲插件之間的低耦合,改變一般是隔離的,能夠快速實現。一般,核心系統是穩定且快速的,具備必定的健壯性,幾乎不須要修改。
  • 可測試性:插件能夠獨立測試,也很容易被模擬,不需修改核心系統就能夠演示或構建新特性的原型。
  • 性能高:雖然微內核架構自己不會使應用高性能,但一般使用微內核架構構建的應用性能都還不錯,由於能夠自定義或者裁剪掉不須要的功能。

介紹完微內核架構相關的基礎知識,接下來咱們將以西瓜視頻播放器爲例,分析一下微內核架構在西瓜視頻播放器中的應用。git

2、西瓜視頻播放器簡介

西瓜視頻播放器一款帶解析器、能節省流量的 HTML5 視頻播放器。它從底層解析 MP四、HLS、FLV 探索更大的視頻播放可控空間。github

xgplayer-demo.jpg

(圖片來源 —— http://h5player.bytedance.com/)shell

它的功能特點是從底層解析 MP四、HLS、FLV 探索更大的視頻播放可控控件並擁有如下特色:數據庫

  1. 易擴展:靈活的插件體系、PC/移動端自動切換、安全的白名單機制;
  2. 更豐富:強大的 MP4 控制、點播的無縫切換、有效的帶寬節省;
  3. 較完整:完整的產品機制、錯誤的監控上報、自動的降級處理。

上手西瓜視頻播放器只需三步:安裝、DOM 佔位、實例化便可完成播放器的使用。安全

xgplayer-quick-start.png

(圖片來源 —— pingan8787)

西瓜視頻播放器主張一切設計都是插件,小到一個播放按鈕大到一項直播功能支持。 想更好的自定義播放器完成本身業務的契合,理解插件機制是很是重要的,播放器自己有不少內置插件,好比報錯、loading、重播等,若是你們想自定義效果能夠關閉內置插件,本身開發便可。

默認狀況下插件是自啓動的,若是自定義插件不想自啓動或者不想改變播放器默認的執行機制,建議以繼承播放器類的方式開發。爲了實現 "一切設計都是插件" 的主張,西瓜視頻播放器團隊採用了微內核的架構,下面咱們開始來分析一下西瓜視頻播放器的微內核實踐。

3、西瓜視頻播放器微內核實踐

微內核架構模式包括兩種類型的架構組件:核心系統和插件模塊。在西瓜視頻播放器中核心繫統是由 Player 類來實現,該類對應的 UML 圖以下所示:

xgplayer-player-uml.png

(https://github.com/bytedance/...

而插件模塊主要就是西瓜視頻播放器中的各類內置插件,好比控制條的音量控制組件、播放器貼圖、播放器畫中畫和播放器下載控件等,除了上面提到的插件以外,目前西瓜視頻播放器總共提供了 22 個插件,完整的內置插件以下圖所示:

xgplayer-build-in-plugins.jpg
(西瓜視頻播放器內置插件)

對於微內核的核心繫統設計來講,它涉及三個關鍵技術:插件管理、插件鏈接和插件通訊。下面咱們將圍繞這三個關鍵點來逐步分析西瓜視頻播放器是如何實現的。

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/...

在以上代碼中,最重要的是最後一行,即 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 和內存、內存和硬盤確定是有通訊的,計算機經過主板上的總線提供了這些組件之間的通訊功能。

computer-bus-structure.png

一樣,咱們以西瓜視頻播放器的內置插件爲切入點來分析插件通訊機制,下面咱們以 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/...

經過觀察源碼可知,該插件首先經過監聽播放器的 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/...

在西瓜視頻播放器初始化的時候,會經過調用 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/...

在西瓜視頻播放器銷燬時,會調用 destroyFunc 方法,在該方法內部,會繼續調用 emit 方法來發射 destroy 事件。以後,若其它插件有監聽 destroy 事件,那麼將會觸發對應的事件處理函數,執行相應的清理工做。而對於插件之間的通訊,一樣也能夠藉助 player 播放器對象上事件相關的 API 來實現,這裏就再也不展開。

前面咱們已經從插件管理、插件鏈接和插件通訊這三方面分析了西瓜視頻播放器是如何實現微內核架構,下面咱們用一張圖來總結一下主要的內容:

xgplayer-arch.jpeg

4、總結

本文以西瓜視頻播放器爲例,詳細介紹了微內核架構的設計要點與實現。其實西瓜視頻播放器除了提供大量的內置插件以外,它也提供了一些功能插件,如 flv 和 hls 功能插件,從而來知足不一樣的播放場景。

此外,經過分析西瓜視頻播放器,咱們發現要設計一個功能完善的組件是頗有挑戰的一件事,要考慮很是多的事情,這裏我以思惟導圖的形式簡單整理了一下,有興趣的讀者能夠參考一下。

xgplayer-design.jpg

想進一步瞭解西瓜視頻播放器的讀者,能夠閱讀我以前整理的 "西瓜視頻播放器功能分析" 這篇文章。

(https://www.yuque.com/docs/sh...

5、參考資源

6、推薦閱讀

相關文章
相關標籤/搜索