前端進階:跟著開源專案學習外掛化架構

阿寶哥發表於2020-06-23

一、微核心架構簡介

1. 1 微核心的概念

微核心架構(Microkernel Architecture),有時也被稱為外掛化架構(Plug-in Architecture),是一種面向功能進行拆分的可擴充套件性架構,通常用於實現基於產品的應用。微核心架構模式允許你將其他應用程式功能作為外掛新增到核心應用程式,從而提供可擴充套件性以及功能分離和隔離。

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

microkernel-architecture-pattern.png

圖中 Core System 的功能相對穩定,不會因為業務功能擴充套件而不斷修改,而外掛模組是可以根據實際業務功能的需要不斷地調整或擴充套件。微核心架構的本質就是將可能需要不斷變化的部分封裝在外掛中,從而達到快速靈活擴充套件的目的,而又不影響整體系統的穩定。

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

外掛模組是獨立的模組,包含特定的處理、額外的功能和自定義程式碼,來向核心系統增強或擴充套件額外的業務能力。通常外掛模組之間也是獨立的,也有一些外掛是依賴於若干其它外掛的。重要的是,儘量減少外掛之間的通訊以避免依賴的問題。

1.2 微核心架構的優點

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

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

二、西瓜視訊播放器簡介

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

xgplayer-demo.jpg

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

它的功能特色是從底層解析 MP4、HLS、FLV 探索更大的視訊播放可控控制元件並擁有以下特點:

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

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

xgplayer-quick-start.png

(圖片來源 —— pingan8787)

西瓜視訊播放器主張一切設計都是外掛,小到一個播放按鈕大到一項直播功能支援。 想更好的自定義播放器完成自己業務的契合,理解外掛機制是非常重要的,播放器本身有很多內建外掛,比如報錯、loading、重播等,如果大家想自定義效果可以關閉內建外掛,自己開發即可。

預設情況下外掛是自啟動的,如果自定義外掛不想自啟動或者不想改變播放器預設的執行機制,建議以繼承播放器類的方式開發。為了實現 "一切設計都是外掛" 的主張,西瓜視訊播放器團隊採用了微核心的架構,下面我們開始來分析一下西瓜視訊播放器的微核心實踐。

三、西瓜視訊播放器微核心實踐

微核心架構模式包括兩種型別的架構元件:核心系統和外掛模組。在西瓜視訊播放器中核心繫統是由 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

四、總結

本文以西瓜視訊播放器為例,詳細介紹了微核心架構的設計要點與實現。其實西瓜視訊播放器除了提供大量的內建外掛之外,它也提供了一些功能外掛,如 flv 和 hls 功能外掛,從而來滿足不同的播放場景。

此外,通過分析西瓜視訊播放器,我們發現要設計一個功能完善的元件是很有挑戰的一件事,要考慮非常多的事情,這裡我以思維導圖的形式簡單整理了一下,有興趣的讀者可以參考一下。

xgplayer-design.jpg

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

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

五、參考資源

六、推薦閱讀

相關文章