從 13K 的前端開源項目我學到了啥?

近期咱們團隊的小夥伴小池同窗分享了 「BetterScroll 2.0 發佈:精益求精,與你同行」 這篇文章到團隊內部羣,看到了 插件化 的架構設計,阿寶哥忽然來了興趣,由於以前阿寶哥在團隊內部也作過相關的分享。既然已經來了興趣,那就決定開啓 BetterScroll 2.0 源碼的學習之旅。vue

接下來本文的重心將圍繞 插件化 的架構設計展開,不過在分析 BetterScroll 2.0 插件化架構以前,咱們先來簡單瞭解一下 BetterScrollnode

BetterScroll 源碼學習腦圖 1.0git

1、BetterScroll 簡介

BetterScroll 是一款重點解決移動端(已支持 PC)各類滾動場景需求的插件。它的核心是借鑑的 iscroll 的實現,它的 API 設計基本兼容 iscroll,在 iscroll 的基礎上又擴展了一些 feature 以及作了一些性能優化。github

BetterScroll 1.0 共發佈了 30 多個版本,npm 月下載量 5 萬,累計 star 數 12600+。那麼爲何升級 2.0 呢?typescript

作 v2 版本的初衷源於社區的一個需求:shell

  • BetterScroll 能不能支持按需加載?

來源於:BetterScroll 2.0 發佈:精益求精,與你同行數據庫

爲了支持插件的按需加載,BetterScroll 2.0 採用了 插件化 的架構設計。CoreScroll 做爲最小的滾動單元,暴露了豐富的事件以及鉤子,其他的功能都由不一樣的插件來擴展,這樣會讓 BetterScroll 使用起來更加的靈活,也能適應不一樣的場景。npm

下面是 BetterScroll 2.0 總體的架構圖:json

(圖片來源:juejin.cn/post/686808…性能優化

該項目採用的是 monorepos 的組織方式,使用 lerna 進行多包管理,每一個組件都是一個獨立的 npm 包:

與西瓜播放器同樣,BetterScroll 2.0 也是採用 插件化 的設計思想,CoreScroll 做爲最小的滾動單元,其他的功能都是經過插件來擴展。好比長列表中常見的上拉加載和下拉刷新功能,在 BetterScroll 2.0 中這些功能分別經過 pull-uppull-down 這兩個插件來實現。

插件化的好處之一就是能夠支持按需加載,此外把獨立功能都拆分紅獨立的插件,會讓核心系統更加穩定,擁有必定的健壯性。好的,簡單介紹了一下 BetterScroll,接下來咱們步入正題來分析一下這個項目中一些值得咱們學習的地方。

2、開發體驗方面

2.1 更好的智能提示

BetterScroll 2.0 採用 TypeScript 進行開發,爲了讓開發者在使用 BetterScroll 時可以擁有較好的智能提示,BetterScroll 團隊充分利用了 TypeScript 接口自動合併的功能,讓開發者在使用某個插件時,可以有對應的 Options 提示以及 bs(BetterScroll 實例)可以有對應的方法提示。

2.1.1 智能插件 Options 提示

2.1.2 智能 BetterScroll 實例方法提示

接下來,爲了後面能更好地理解 BetterScroll 的設計思想,咱們先來簡單介紹一下插件化架構。

3、插件化架構簡介

3.1 插件化架構的概念

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

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

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

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

3.2 插件化架構的優勢

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

介紹完插件化架構相關的基礎知識,接下來咱們來分析一下 BetterScroll 2.0 是如何設計插件化架構的。

4、BetterScroll 插件化架構實現

對於插件化的核心繫統設計來講,它涉及三個關鍵點:插件管理、插件鏈接和插件通訊。下面咱們將圍繞這三個關鍵點來逐步分析 BetterScroll 2.0 是如何實現插件化架構。

4.1 插件管理

爲了統一管理內置的插件,也方便開發者根據業務需求開發符合規範的自定義插件。BetterScroll 2.0 約定了統一的插件開發規範。 BetterScroll 2.0 的插件須要是一個類,而且具備如下特性:

1.靜態的 pluginName 屬性;

2.實現 PluginAPI 接口(當且僅當須要把插件方法代理至 bs);

3.constructor 的第一個參數就是 BetterScroll 實例 bs,你能夠經過 bs 的 事件 或者 鉤子 來注入本身的邏輯。

這裏爲了直觀地理解以上的開發規範,咱們將之內置的 PullUp 插件爲例,來看一下它是如何實現上述規範的。PullUp 插件爲 BetterScroll 擴展上拉加載的能力。

顧名思義,靜態的 pluginName 屬性表示插件的名稱,而 PluginAPI 接口表示插件實例對外提供的 API 接口,經過 PluginAPI 接口可知它支持 4 個方法:

  • finishPullUp(): void:結束上拉加載行爲;
  • openPullUp(config?: PullUpLoadOptions): void:動態開啓上拉功能;
  • closePullUp(): void:關閉上拉加載功能;
  • autoPullUpLoad(): void:自動執行上拉加載。

插件經過構造函數注入 BetterScroll 實例 bs,以後咱們就能夠經過 bs 的事件或者鉤子來注入本身的邏輯。那麼爲何要注入 bs 實例?如何利用 bs 實例?這裏咱們先記住這些問題,後面咱們再來分析它們。

4.2 插件鏈接

核心系統須要知道當前有哪些插件可用,如何加載這些插件,何時加載插件。常見的實現方法是插件註冊表機制。核心系統提供插件註冊表(能夠是配置文件,也能夠是代碼,還能夠是數據庫),插件註冊表含有每一個插件模塊的信息,包括它的名字、位置、加載時機(啓動就加載,或是按需加載)等。

這裏咱們之前面提到的 PullUp 插件爲例,來看一下如何註冊和使用該插件。首先你須要使用如下命令安裝 PullUp 插件:

$ npm install @better-scroll/pull-up --save
複製代碼

成功安裝完 pullup 插件以後,你須要經過 BScroll.use 方法來註冊插件:

import BScroll from '@better-scroll/core'
import Pullup from '@better-scroll/pull-up'

BScroll.use(Pullup)
複製代碼

而後,實例化 BetterScroll 時須要傳入 PullUp 插件的配置項。

new BScroll('.bs-wrapper', {
  pullUpLoad: true
})
複製代碼

如今咱們已經知道經過 BScroll.use 方法能夠註冊插件,那麼該方法內部作了哪些處理?要回答這個問題,咱們來看一下對應的源碼:

// better-scroll/packages/core/src/BScroll.ts
export const BScroll = (createBScroll as unknown) as BScrollFactory
createBScroll.use = BScrollConstructor.use
複製代碼

BScroll.ts 文件中, BScroll.use 方法指向的是 BScrollConstructor.use 靜態方法,該方法的實現以下:

export class BScrollConstructor<O = {}> extends EventEmitter {
  static plugins: PluginItem[] = []
  static pluginsMap: PluginsMap = {}

  static use(ctor: PluginCtor) {
    const name = ctor.pluginName
    const installed = BScrollConstructor.plugins.some(
      (plugin) => ctor === plugin.ctor
    )
    // 省略部分代碼
    if (installed) return BScrollConstructor
    BScrollConstructor.pluginsMap[name] = true
    BScrollConstructor.plugins.push({
      name,
      applyOrder: ctor.applyOrder,
      ctor,
    })
    return BScrollConstructor
  }
}
複製代碼

經過觀察以上代碼,可知 use 方法接收一個參數,該參數的類型是 PluginCtor,用於描述插件構造函數的特色。PluginCtor 類型的具體聲明以下所示:

interface PluginCtor {
  pluginName: string
  applyOrder?: ApplyOrder
  new (scroll: BScroll): any
}
複製代碼

當咱們調用 BScroll.use(Pullup) 方法時,會先獲取當前插件的名稱,而後判斷當前插件是否已經安裝過了。若是已經安裝則直接返回 BScrollConstructor 對象,不然會對插件進行註冊。即把當前插件的信息分別保存到 pluginsMap({}) 和 plugins([]) 對象中:

另外調用 use 靜態方法後,會返回 BScrollConstructor 對象,這是爲了支持鏈式調用:

BScroll.use(MouseWheel)
  .use(ObserveDom)
  .use(PullDownRefresh)
  .use(PullUpLoad)
複製代碼

如今咱們已經知道 BScroll.use 方法內部是如何註冊插件的,註冊插件只是第一步,要使用已註冊的插件,咱們還須要在實例化 BetterScroll 時傳入插件的配置項,從而進行插件的初始化。對於 PullUp 插件,咱們經過如下方式進行插件的初始化。

new BScroll('.bs-wrapper', {
  pullUpLoad: true
})
複製代碼

因此想了解插件是如何鏈接到核心系統並進行插件初始化,咱們就須要來分析一下 BScroll 構造函數:

// packages/core/src/BScroll.ts
export const BScroll = (createBScroll as unknown) as BScrollFactory

export function createBScroll<O = {}>(
  el: ElementParam,
  options?: Options & O
): BScrollConstructor & UnionToIntersection<ExtractAPI<O>> {
  const bs = new BScrollConstructor(el, options)
  return (bs as unknown) as BScrollConstructor &
    UnionToIntersection<ExtractAPI<O>>
}
複製代碼

createBScroll 工廠方法內部會經過 new 關鍵字調用 BScrollConstructor 構造函數來建立 BetterScroll 實例。所以接下來的重點就是分析 BScrollConstructor 構造函數:

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {
  constructor(el: ElementParam, options?: Options & O) {
    const wrapper = getElement(el)
    // 省略部分代碼
    this.plugins = {}
    this.hooks = new EventEmitter([...])
    this.init(wrapper)
  }
  
  private init(wrapper: MountedBScrollHTMLElement) {
    this.wrapper = wrapper
    // 省略部分代碼
    this.applyPlugins()
  }
}
複製代碼

經過閱讀 BScrollConstructor 的源碼,咱們發如今 BScrollConstructor 構造函數內部會調用 init 方法進行初始化,而在 init 方法內部會進一步調用 applyPlugins 方法來應用已註冊的插件:

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {  
  private applyPlugins() {
    const options = this.options
    BScrollConstructor.plugins
      .sort((a, b) => {
        const applyOrderMap = {
          [ApplyOrder.Pre]: -1,
          [ApplyOrder.Post]: 1,
        }
        const aOrder = a.applyOrder ? applyOrderMap[a.applyOrder] : 0
        const bOrder = b.applyOrder ? applyOrderMap[b.applyOrder] : 0
        return aOrder - bOrder
      })
      .forEach((item: PluginItem) => {
        const ctor = item.ctor
				// 當啓用指定插件的時候且插件構造函數的類型是函數的話,再建立對應的插件
        if (options[item.name] && typeof ctor === 'function') {
          this.plugins[item.name] = new ctor(this)
        }
      })
  }
}
複製代碼

applyPlugins 方法內部會根據插件設置的順序進行排序,而後會使用 bs 實例做爲參數調用插件的構造函數來建立插件,並把插件的實例保存到 bs 實例內部的 plugins({}) 屬性中。

到這裏咱們已經介紹了插件管理和插件鏈接,下面咱們來介紹最後一個關鍵點 —— 插件通訊。

4.3 插件通訊

插件通訊是指插件間的通訊。雖然設計的時候插件間是徹底解耦的,但實際業務運行過程當中,必然會出現某個業務流程須要多個插件協做,這就要求兩個插件間進行通訊; 因爲插件之間沒有直接聯繫,通訊必須經過核心系統,所以核心系統須要提供插件通訊機制

這種狀況和計算機相似,計算機的 CPU、硬盤、內存、網卡是獨立設計的配置,但計算機運行過程當中,CPU 和內存、內存和硬盤確定是有通訊的,計算機經過主板上的總線提供了這些組件之間的通訊功能。

一樣,對於插件化架構的系統來講,一般核心系統會以事件總線的形式提供插件通訊機制。提到事件總線,可能有一些小夥伴會有一些陌生。但若是說是使用了 發佈訂閱模式 的話,應該就很容易理解了。這裏阿寶哥不打算在展開介紹發佈訂閱模式,只用一張圖來回顧一下該模式。

對於 BetterScroll 來講,它的核心是 BScrollConstructor 類,該類繼承了 EventEmitter 事件派發器:

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {  
  constructor(el: ElementParam, options?: Options & O) {
    this.hooks = new EventEmitter([
      'refresh',
      'enable',
      'disable',
      'destroy',
      'beforeInitialScrollTo',
      'contentChanged',
    ])
    this.init(wrapper)
  }
}
複製代碼

EventEmitter 類是由 BetterScroll 內部提供的,它的實例將會對外提供事件總線的功能,而該類對應的 UML 類圖以下所示:

講到這裏咱們就能夠來回答前面留下的第一個問題:「那麼爲何要注入 bs 實例?」。由於 bs(BScrollConstructor)實例的本質也是一個事件派發器,在建立插件時,注入 bs 實例是爲了讓插件間能經過統一的事件派發器進行通訊。

第一個問題咱們已經知道答案了,接下來咱們來看第二個問題:」如何利用 bs 實例?「。要回答這個問題,咱們將繼續以 PullUp 插件爲例,來看一下該插件內部是如何利用 bs 實例進行消息通訊的。

export default class PullUp implements PluginAPI {
  static pluginName = 'pullUpLoad'
  constructor(public scroll: BScroll) {
    this.init()
  }
}
複製代碼

在 PullUp 構造函數中,bs 實例會被保存到 PullUp 實例內部的 scroll 屬性中,以後在 PullUp 插件內部就能夠經過注入的 bs 實例來進行事件通訊。好比派發插件的內部事件,在 PullUp 插件中,當距離滾動到底部小於 threshold 值時,觸發一次 pullingUp 事件:

private checkPullUp(pos: { x: number; y: number }) {
  const { threshold } = this.options
  if (...) {
      this.pulling = true
      // 省略部分代碼
      this.scroll.trigger(PULL_UP_HOOKS_NAME) // 'pullingUp'
  }
}
複製代碼

知道如何利用 bs 實例派發事件以後,咱們再來看一下在插件內部如何利用它來監聽插件所感興趣的事件。

// packages/pull-up/src/index.ts
export default class PullUp implements PluginAPI {
  static pluginName = 'pullUpLoad'
  constructor(public scroll: BScroll) {
    this.init()
  }

  private init() {
    this.handleBScroll()
    this.handleOptions(this.scroll.options.pullUpLoad)
    this.handleHooks()
    this.watch()
  }
}
複製代碼

在 PullUp 構造函數中會調用 init 方法進行插件初始化,而在 init 方法內部會分別調用不一樣的方法執行不一樣的初始化操做,這裏跟事件相關的是 handleHooks 方法,該方法的實現以下:

private handleHooks() {
  this.hooksFn = []
  // 省略部分代碼
  this.registerHooks(
    this.scroll.hooks,
    this.scroll.hooks.eventTypes.contentChanged,
    () => {
      this.finishPullUp()
    }
  )
}
複製代碼

很明顯在 handleHooks 方法內部,會進一步調用 registerHooks 方法來註冊鉤子:

private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
  hooks.on(name, handler, this)
  this.hooksFn.push([hooks, name, handler])
}
複製代碼

經過觀察 registerHooks 方法的簽名可知,它支持 3 個參數,第 1 個參數是 EventEmitter 對象,而另外 2 個參數分別表示事件名和事件處理器。在 registerHooks 方法內部,它就是簡單地經過 hooks 對象來監聽指定的事件。

那麼 this.scroll.hooks 對象是何時建立的呢?在 BScrollConstructor 構造函數中咱們找到了答案。

// packages/core/src/BScroll.ts
export class BScrollConstructor<O = {}> extends EventEmitter {
  constructor(el: ElementParam, options?: Options & O) {
    // 省略部分代碼
    this.hooks = new EventEmitter([
      'refresh',
      'enable',
      'disable',
      'destroy',
      'beforeInitialScrollTo',
      'contentChanged',
    ]) 
  }
}
複製代碼

很明顯 this.hooks 也是一個 EventEmitter 對象,因此能夠經過它來進行事件處理。好的,插件通訊的內容就先介紹到這裏,下面咱們用一張圖來總結一下該部分的內容:

介紹完 BetterScroll 插件化架構的實現,最後咱們來簡單聊一下 BetterScroll 項目工程化方面的內容。

5、工程化方面

在工程化方面,BetterScroll 使用了業內一些常見的解決方案:

  • lerna:Lerna 是一個管理工具,用於管理包含多個軟件包(package)的 JavaScript 項目。
  • prettier:Prettier 中文的意思是漂亮的、美麗的,是一個流行的代碼格式化的工具。
  • tslint:TSLint 是可擴展的靜態分析工具,用於檢查 TypeScript 代碼的可讀性,可維護性和功能性錯誤。
  • commitizen & cz-conventional-changelog:用於幫助咱們生成符合規範的 commit message。
  • husky:husky 可以防止不規範代碼被 commit、push、merge 等等。
  • jest:Jest 是由 Facebook 維護的 JavaScript 測試框架。
  • coveralls:用於獲取 Coveralls.io 的覆蓋率報告,並在 README 文件中添加一個不錯的覆蓋率按鈕。
  • vuepress:Vue 驅動的靜態網站生成器,它用於生成 BetterScroll 2.0 的文檔。

由於本文的重點不在工程化,因此上面阿寶哥只是簡單羅列了 BetterScroll 在工程化方面使用的開源庫。若是你對 BetterScroll 項目也感興趣的話,能夠看看項目中的 package.json 文件,並重點看一下項目中 npm scripts 的配置。固然 BetterScroll 項目還有不少值得學習的地方,剩下的就等你們去發掘吧,歡迎感興趣的小夥伴跟阿寶哥一塊兒交流與討論。

6、參考資源

相關文章
相關標籤/搜索