近期咱們團隊的小夥伴小池同窗分享了 「BetterScroll 2.0 發佈:精益求精,與你同行」 這篇文章到團隊內部羣,看到了 插件化 的架構設計,阿寶哥忽然來了興趣,由於以前阿寶哥在團隊內部也作過相關的分享。既然已經來了興趣,那就決定開啓 BetterScroll 2.0 源碼的學習之旅。vue
接下來本文的重心將圍繞 插件化 的架構設計展開,不過在分析 BetterScroll 2.0 插件化架構以前,咱們先來簡單瞭解一下 BetterScroll。node
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 採用了 插件化 的架構設計。CoreScroll 做爲最小的滾動單元,暴露了豐富的事件以及鉤子,其他的功能都由不一樣的插件來擴展,這樣會讓 BetterScroll 使用起來更加的靈活,也能適應不一樣的場景。npm
下面是 BetterScroll 2.0 總體的架構圖:json
(圖片來源:juejin.cn/post/686808…性能優化
該項目採用的是 monorepos 的組織方式,使用 lerna 進行多包管理,每一個組件都是一個獨立的 npm 包:
與西瓜播放器同樣,BetterScroll 2.0 也是採用 插件化 的設計思想,CoreScroll 做爲最小的滾動單元,其他的功能都是經過插件來擴展。好比長列表中常見的上拉加載和下拉刷新功能,在 BetterScroll 2.0 中這些功能分別經過 pull-up
和 pull-down
這兩個插件來實現。
插件化的好處之一就是能夠支持按需加載,此外把獨立功能都拆分紅獨立的插件,會讓核心系統更加穩定,擁有必定的健壯性。好的,簡單介紹了一下 BetterScroll,接下來咱們步入正題來分析一下這個項目中一些值得咱們學習的地方。
BetterScroll 2.0 採用 TypeScript 進行開發,爲了讓開發者在使用 BetterScroll 時可以擁有較好的智能提示,BetterScroll 團隊充分利用了 TypeScript 接口自動合併的功能,讓開發者在使用某個插件時,可以有對應的 Options 提示以及 bs(BetterScroll 實例)可以有對應的方法提示。
接下來,爲了後面能更好地理解 BetterScroll 的設計思想,咱們先來簡單介紹一下插件化架構。
插件化架構(Plug-in Architecture),是一種面向功能進行拆分的可擴展性架構,一般用於實現基於產品的應用。插件化架構模式容許你將其餘應用程序功能做爲插件添加到核心應用程序,從而提供可擴展性以及功能分離和隔離。
插件化架構模式包括兩種類型的架構組件:核心系統(Core System)和插件模塊(Plug-in modules)。應用邏輯被分割爲獨立的插件模塊和核心繫統,提供了可擴展性、靈活性、功能隔離和自定義處理邏輯的特性。
圖中 Core System 的功能相對穩定,不會由於業務功能擴展而不斷修改,而插件模塊是能夠根據實際業務功能的須要不斷地調整或擴展。 插件化架構的本質就是將可能須要不斷變化的部分封裝在插件中,從而達到快速靈活擴展的目的,而又不影響總體系統的穩定。
插件化架構的核心繫統一般提供系統運行所需的最小功能集。插件模塊是獨立的模塊,包含特定的處理、額外的功能和自定義代碼,來向核心系統加強或擴展額外的業務能力。 一般插件模塊之間也是獨立的,也有一些插件是依賴於若干其它插件的。重要的是,儘可能減小插件之間的通訊以免依賴的問題。
介紹完插件化架構相關的基礎知識,接下來咱們來分析一下 BetterScroll 2.0 是如何設計插件化架構的。
對於插件化的核心繫統設計來講,它涉及三個關鍵點:插件管理、插件鏈接和插件通訊。下面咱們將圍繞這三個關鍵點來逐步分析 BetterScroll 2.0 是如何實現插件化架構。
爲了統一管理內置的插件,也方便開發者根據業務需求開發符合規範的自定義插件。BetterScroll 2.0 約定了統一的插件開發規範。 BetterScroll 2.0 的插件須要是一個類,而且具備如下特性:
1.靜態的 pluginName 屬性;
2.實現 PluginAPI 接口(當且僅當須要把插件方法代理至 bs);
3.constructor 的第一個參數就是 BetterScroll 實例 bs
,你能夠經過 bs 的 事件 或者 鉤子 來注入本身的邏輯。
這裏爲了直觀地理解以上的開發規範,咱們將之內置的 PullUp 插件爲例,來看一下它是如何實現上述規範的。PullUp 插件爲 BetterScroll 擴展上拉加載的能力。
顧名思義,靜態的 pluginName
屬性表示插件的名稱,而 PluginAPI 接口表示插件實例對外提供的 API 接口,經過 PluginAPI 接口可知它支持 4 個方法:
插件經過構造函數注入 BetterScroll 實例 bs
,以後咱們就能夠經過 bs 的事件或者鉤子來注入本身的邏輯。那麼爲何要注入 bs 實例?如何利用 bs 實例?這裏咱們先記住這些問題,後面咱們再來分析它們。
核心系統須要知道當前有哪些插件可用,如何加載這些插件,何時加載插件。常見的實現方法是插件註冊表機制。核心系統提供插件註冊表(能夠是配置文件,也能夠是代碼,還能夠是數據庫),插件註冊表含有每一個插件模塊的信息,包括它的名字、位置、加載時機(啓動就加載,或是按需加載)等。
這裏咱們之前面提到的 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({}) 屬性中。
到這裏咱們已經介紹了插件管理和插件鏈接,下面咱們來介紹最後一個關鍵點 —— 插件通訊。
插件通訊是指插件間的通訊。雖然設計的時候插件間是徹底解耦的,但實際業務運行過程當中,必然會出現某個業務流程須要多個插件協做,這就要求兩個插件間進行通訊; 因爲插件之間沒有直接聯繫,通訊必須經過核心系統,所以核心系統須要提供插件通訊機制。
這種狀況和計算機相似,計算機的 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 項目工程化方面的內容。
在工程化方面,BetterScroll 使用了業內一些常見的解決方案:
由於本文的重點不在工程化,因此上面阿寶哥只是簡單羅列了 BetterScroll 在工程化方面使用的開源庫。若是你對 BetterScroll 項目也感興趣的話,能夠看看項目中的 package.json
文件,並重點看一下項目中 npm scripts 的配置。固然 BetterScroll 項目還有不少值得學習的地方,剩下的就等你們去發掘吧,歡迎感興趣的小夥伴跟阿寶哥一塊兒交流與討論。