從 12.9K 的前端開源項目我學(xué)到了啥?
接下來本文的重心將圍繞 插件化 的架構(gòu)設(shè)計展開,不過在分析 BetterScroll 2.0 插件化架構(gòu)之前,我們先來簡單了解一下 BetterScroll。
一、BetterScroll 簡介
BetterScroll 是一款重點解決移動端(已支持 PC)各種滾動場景需求的插件。它的核心是借鑒的 iscroll 的實現(xiàn),它的 API 設(shè)計基本兼容 iscroll,在 iscroll 的基礎(chǔ)上又?jǐn)U展了一些 feature 以及做了一些性能優(yōu)化。
BetterScroll 1.0 共發(fā)布了 30 多個版本,npm 月下載量 5 萬,累計 star 數(shù) 12600+。那么為什么升級 2.0 呢?
- 做 v2 版本的初衷源于社區(qū)的一個需求:
- BetterScroll 能不能支持按需加載?
- 來源于:BetterScroll 2.0 發(fā)布:精益求精,與你同行
為了支持插件的按需加載,BetterScroll 2.0 采用了 插件化 的架構(gòu)設(shè)計。CoreScroll 作為最小的滾動單元,暴露了豐富的事件以及鉤子,其余的功能都由不同的插件來擴(kuò)展,這樣會讓 BetterScroll 使用起來更加的靈活,也能適應(yīng)不同的場景。
下面是 BetterScroll 2.0 整體的架構(gòu)圖:
該項目采用的是 monorepos 的組織方式,使用 lerna 進(jìn)行多包管理,每個組件都是一個獨立的 npm 包:
與西瓜播放器一樣,BetterScroll 2.0 也是采用 插件化 的設(shè)計思想,CoreScroll 作為最小的滾動單元,其余的功能都是通過插件來擴(kuò)展。比如長列表中常見的上拉加載和下拉刷新功能,在 BetterScroll 2.0 中這些功能分別通過 pull-up 和 pull-down 這兩個插件來實現(xiàn)。
插件化的好處之一就是可以支持按需加載,此外把獨立功能都拆分成獨立的插件,會讓核心系統(tǒng)更加穩(wěn)定,擁有一定的健壯性。
好的,簡單介紹了一下 BetterScroll,接下來我們步入正題來分析一下這個項目中一些值得我們學(xué)習(xí)的地方。
二、開發(fā)體驗方面
2.1 更好的智能提示
BetterScroll 2.0 采用 TypeScript 進(jìn)行開發(fā),為了讓開發(fā)者在使用 BetterScroll 時能夠擁有較好的智能提示,BetterScroll 團(tuán)隊充分利用了 TypeScript 接口自動合并的功能,讓開發(fā)者在使用某個插件時,能夠有對應(yīng)的 Options 提示以及 bs(BetterScroll 實例)能夠有對應(yīng)的方法提示。
2.1.1 智能插件 Options 提示
2.1.2 智能 BetterScroll 實例方法提示
接下來,為了后面能更好地理解 BetterScroll 的設(shè)計思想,我們先來簡單介紹一下插件化架構(gòu)。
三、插件化架構(gòu)簡介
3.1 插件化架構(gòu)的概念
插件化架構(gòu)(Plug-in Architecture),是一種面向功能進(jìn)行拆分的可擴(kuò)展性架構(gòu),通常用于實現(xiàn)基于產(chǎn)品的應(yīng)用。插件化架構(gòu)模式允許你將其他應(yīng)用程序功能作為插件添加到核心應(yīng)用程序,從而提供可擴(kuò)展性以及功能分離和隔離。
插件化架構(gòu)模式包括兩種類型的架構(gòu)組件:核心系統(tǒng)(Core System)和插件模塊(Plug-in modules)。應(yīng)用邏輯被分割為獨立的插件模塊和核心系統(tǒng),提供了可擴(kuò)展性、靈活性、功能隔離和自定義處理邏輯的特性。
圖中 Core System 的功能相對穩(wěn)定,不會因為業(yè)務(wù)功能擴(kuò)展而不斷修改,而插件模塊是可以根據(jù)實際業(yè)務(wù)功能的需要不斷地調(diào)整或擴(kuò)展。插件化架構(gòu)的本質(zhì)就是將可能需要不斷變化的部分封裝在插件中,從而達(dá)到快速靈活擴(kuò)展的目的,而又不影響整體系統(tǒng)的穩(wěn)定。
插件化架構(gòu)的核心系統(tǒng)通常提供系統(tǒng)運行所需的最小功能集。插件模塊是獨立的模塊,包含特定的處理、額外的功能和自定義代碼,來向核心系統(tǒng)增強(qiáng)或擴(kuò)展額外的業(yè)務(wù)能力。通常插件模塊之間也是獨立的,也有一些插件是依賴于若干其它插件的。重要的是,盡量減少插件之間的通信以避免依賴的問題。
3.2 插件化架構(gòu)的優(yōu)點
靈活性高:整體靈活性是對環(huán)境變化快速響應(yīng)的能力。由于插件之間的低耦合,改變通常是隔離的,可以快速實現(xiàn)。
可測試性:插件可以獨立測試,也很容易被模擬,不需修改核心系統(tǒng)就可以演示或構(gòu)建新特性的原型。
性能高:雖然插件化架構(gòu)本身不會使應(yīng)用高性能,但通常使用插件化架構(gòu)構(gòu)建的應(yīng)用性能都還不錯,因為可以自定義或者裁剪掉不需要的功能。
介紹完插件化架構(gòu)相關(guān)的基礎(chǔ)知識,接下來我們來分析一下 BetterScroll 2.0 是如何設(shè)計插件化架構(gòu)的。
四、BetterScroll 插件化架構(gòu)實現(xiàn)
對于插件化的核心系統(tǒng)設(shè)計來說,它涉及三個關(guān)鍵點:插件管理、插件連接和插件通信。下面我們將圍繞這三個關(guān)鍵點來逐步分析 BetterScroll 2.0 是如何實現(xiàn)插件化架構(gòu)。
4.1 插件管理
為了統(tǒng)一管理內(nèi)置的插件,也方便開發(fā)者根據(jù)業(yè)務(wù)需求開發(fā)符合規(guī)范的自定義插件。BetterScroll 2.0 約定了統(tǒng)一的插件開發(fā)規(guī)范。BetterScroll 2.0 的插件需要是一個類,并且具有以下特性:
1.靜態(tài)的 pluginName 屬性;
2.實現(xiàn) PluginAPI 接口(當(dāng)且僅當(dāng)需要把插件方法代理至 bs);
3.constructor 的第一個參數(shù)就是 BetterScroll 實例 bs,你可以通過 bs 的 事件 或者 鉤子 來注入自己的邏輯。
這里為了直觀地理解以上的開發(fā)規(guī)范,我們將以內(nèi)置的 PullUp 插件為例,來看一下它是如何實現(xiàn)上述規(guī)范的。PullUp 插件為 BetterScroll 擴(kuò)展上拉加載的能力。
顧名思義,靜態(tài)的 pluginName 屬性表示插件的名稱,而 PluginAPI 接口表示插件實例對外提供的 API 接口,通過 PluginAPI 接口可知它支持 4 個方法:
finishPullUp(): void:結(jié)束上拉加載行為;
openPullUp(config?: PullUpLoadOptions): void:動態(tài)開啟上拉功能;
closePullUp(): void:關(guān)閉上拉加載功能;
autoPullUpLoad(): void:自動執(zhí)行上拉加載。
插件通過構(gòu)造函數(shù)注入 BetterScroll 實例 bs,之后我們就可以通過 bs 的事件或者鉤子來注入自己的邏輯。那么為什么要注入 bs 實例?如何利用 bs 實例?這里我們先記住這些問題,后面我們再來分析它們。
4.2 插件連接
核心系統(tǒng)需要知道當(dāng)前有哪些插件可用,如何加載這些插件,什么時候加載插件。常見的實現(xiàn)方法是插件注冊表機(jī)制。核心系統(tǒng)提供插件注冊表(可以是配置文件,也可以是代碼,還可以是數(shù)據(jù)庫),插件注冊表含有每個插件模塊的信息,包括它的名字、位置、加載時機(jī)(啟動就加載,或是按需加載)等。
這里我們以前面提到的 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
- })
現(xiàn)在我們已經(jīng)知道通過 BScroll.use 方法可以注冊插件,那么該方法內(nèi)部做了哪些處理?要回答這個問題,我們來看一下對應(yīng)的源碼:
- // 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 靜態(tài)方法,該方法的實現(xiàn)如下:
- 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 方法接收一個參數(shù),該參數(shù)的類型是 PluginCtor,用于描述插件構(gòu)造函數(shù)的特點。PluginCtor 類型的具體聲明如下所示:
- interface PluginCtor {
- pluginName: string
- applyOrder?: ApplyOrder
- new (scroll: BScroll): any
- }
當(dāng)我們調(diào)用 BScroll.use(Pullup) 方法時,會先獲取當(dāng)前插件的名稱,然后判斷當(dāng)前插件是否已經(jīng)安裝過了。如果已經(jīng)安裝則直接返回 BScrollConstructor 對象,否則會對插件進(jìn)行注冊。即把當(dāng)前插件的信息分別保存到 pluginsMap({}) 和 plugins([]) 對象中:
另外調(diào)用 use 靜態(tài)方法后,會返回 BScrollConstructor 對象,這是為了支持鏈?zhǔn)秸{(diào)用:
- BScroll.use(MouseWheel)
- .use(ObserveDom)
- .use(PullDownRefresh)
- .use(PullUpLoad)
現(xiàn)在我們已經(jīng)知道 BScroll.use 方法內(nèi)部是如何注冊插件的,注冊插件只是第一步,要使用已注冊的插件,我們還需要在實例化 BetterScroll 時傳入插件的配置項,從而進(jìn)行插件的初始化。對于 PullUp 插件,我們通過以下方式進(jìn)行插件的初始化。
- new BScroll('.bs-wrapper', {
- pullUpLoad: true
- })
所以想了解插件是如何連接到核心系統(tǒng)并進(jìn)行插件初始化,我們就需要來分析一下 BScroll 構(gòu)造函數(shù):
- // 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 工廠方法內(nèi)部會通過 new 關(guān)鍵字調(diào)用 BScrollConstructor 構(gòu)造函數(shù)來創(chuàng)建 BetterScroll 實例。因此接下來的重點就是分析 BScrollConstructor 構(gòu)造函數(shù):
- // 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 的源碼,我們發(fā)現(xiàn)在 BScrollConstructor 構(gòu)造函數(shù)內(nèi)部會調(diào)用 init 方法進(jìn)行初始化,而在 init 方法內(nèi)部會進(jìn)一步調(diào)用 applyPlugins 方法來應(yīng)用已注冊的插件:
- // 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
- // 當(dāng)啟用指定插件的時候且插件構(gòu)造函數(shù)的類型是函數(shù)的話,再創(chuàng)建對應(yīng)的插件
- if (options[item.name] && typeof ctor === 'function') {
- this.plugins[item.name] = new ctor(this)
- }
- })
- }
- }
在 applyPlugins 方法內(nèi)部會根據(jù)插件設(shè)置的順序進(jìn)行排序,然后會使用 bs 實例作為參數(shù)調(diào)用插件的構(gòu)造函數(shù)來創(chuàng)建插件,并把插件的實例保存到 bs 實例內(nèi)部的 plugins({}) 屬性中。
到這里我們已經(jīng)介紹了插件管理和插件連接,下面我們來介紹最后一個關(guān)鍵點 —— 插件通信。
4.3 插件通信
插件通信是指插件間的通信。雖然設(shè)計的時候插件間是完全解耦的,但實際業(yè)務(wù)運行過程中,必然會出現(xiàn)某個業(yè)務(wù)流程需要多個插件協(xié)作,這就要求兩個插件間進(jìn)行通信;由于插件之間沒有直接聯(lián)系,通信必須通過核心系統(tǒng),因此核心系統(tǒng)需要提供插件通信機(jī)制。
這種情況和計算機(jī)類似,計算機(jī)的 CPU、硬盤、內(nèi)存、網(wǎng)卡是獨立設(shè)計的配置,但計算機(jī)運行過程中,CPU 和內(nèi)存、內(nèi)存和硬盤肯定是有通信的,計算機(jī)通過主板上的總線提供了這些組件之間的通信功能。
同樣,對于插件化架構(gòu)的系統(tǒng)來說,通常核心系統(tǒng)會以事件總線的形式提供插件通信機(jī)制。提到事件總線,可能有一些小伙伴會有一些陌生。但如果說是使用了 發(fā)布訂閱模式 的話,應(yīng)該就很容易理解了。這里阿寶哥不打算在展開介紹發(fā)布訂閱模式,只用一張圖來回顧一下該模式。
對于 BetterScroll 來說,它的核心是 BScrollConstructor 類,該類繼承了 EventEmitter 事件派發(fā)器:
- // 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 內(nèi)部提供的,它的實例將會對外提供事件總線的功能,而該類對應(yīng)的 UML 類圖如下所示:
講到這里我們就可以來回答前面留下的第一個問題:“那么為什么要注入 bs 實例?”。因為 bs(BScrollConstructor)實例的本質(zhì)也是一個事件派發(fā)器,在創(chuàng)建插件時,注入 bs 實例是為了讓插件間能通過統(tǒng)一的事件派發(fā)器進(jìn)行通信。
第一個問題我們已經(jīng)知道答案了,接下來我們來看第二個問題:”如何利用 bs 實例?“。要回答這個問題,我們將繼續(xù)以 PullUp 插件為例,來看一下該插件內(nèi)部是如何利用 bs 實例進(jìn)行消息通信的。
- export default class PullUp implements PluginAPI {
- static pluginName = 'pullUpLoad'
- constructor(public scroll: BScroll) {
- this.init()
- }
- }
在 PullUp 構(gòu)造函數(shù)中,bs 實例會被保存到 PullUp 實例內(nèi)部的 scroll 屬性中,之后在 PullUp 插件內(nèi)部就可以通過注入的 bs 實例來進(jìn)行事件通信。比如派發(fā)插件的內(nèi)部事件,在 PullUp 插件中,當(dāng)距離滾動到底部小于 threshold 值時,觸發(fā)一次 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 實例派發(fā)事件之后,我們再來看一下在插件內(nèi)部如何利用它來監(jiān)聽插件所感興趣的事件
- // 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 構(gòu)造函數(shù)中會調(diào)用 init 方法進(jìn)行插件初始化,而在 init 方法內(nèi)部會分別調(diào)用不同的方法執(zhí)行不同的初始化操作,這里跟事件相關(guān)的是 handleHooks 方法,該方法的實現(xiàn)如下:
- private handleHooks() {
- this.hooksFn = []
- // 省略部分代碼
- this.registerHooks(
- this.scroll.hooks,
- this.scroll.hooks.eventTypes.contentChanged,
- () => {
- this.finishPullUp()
- }
- )
- }
很明顯在 handleHooks 方法內(nèi)部,會進(jìn)一步調(diào)用 registerHooks 方法來注冊鉤子:
- private registerHooks(hooks: EventEmitter, name: string, handler: Function) {
- hooks.on(name, handler, this)
- this.hooksFn.push([hooks, name, handler])
- }
通過觀察 registerHooks 方法的簽名可知,它支持 3 個參數(shù),第 1 個參數(shù)是 EventEmitter 對象,而另外 2 個參數(shù)分別表示事件名和事件處理器。在 registerHooks 方法內(nèi)部,它就是簡單地通過 hooks 對象來監(jiān)聽指定的事件。
那么 this.scroll.hooks 對象是什么時候創(chuàng)建的呢?在 BScrollConstructor 構(gòu)造函數(shù)中我們找到了答案。
- // 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 對象,所以可以通過它來進(jìn)行事件處理。好的,插件通信的內(nèi)容就先介紹到這里,下面我們用一張圖來總結(jié)一下該部分的內(nèi)容:
介紹完 BetterScroll 插件化架構(gòu)的實現(xiàn),最后我們來簡單聊一下 BetterScroll 項目工程化方面的內(nèi)容。
五、工程化方面
在工程化方面,BetterScroll 使用了業(yè)內(nèi)一些常見的解決方案:
lerna:Lerna 是一個管理工具,用于管理包含多個軟件包(package)的 JavaScript 項目。
prettier:Prettier 中文的意思是漂亮的、美麗的,是一個流行的代碼格式化的工具。
tslint:TSLint 是可擴(kuò)展的靜態(tài)分析工具,用于檢查 TypeScript 代碼的可讀性,可維護(hù)性和功能性錯誤。
commitizen & cz-conventional-changelog:用于幫助我們生成符合規(guī)范的 commit message。
husky:husky 能夠防止不規(guī)范代碼被 commit、push、merge 等等。
jest:Jest 是由 Facebook 維護(hù)的 JavaScript 測試框架。
coveralls:用于獲取 Coveralls.io 的覆蓋率報告,并在 README 文件中添加一個不錯的覆蓋率按鈕。
vuepress:Vue 驅(qū)動的靜態(tài)網(wǎng)站生成器,它用于生成 BetterScroll 2.0 的文檔。
因為本文的重點不在工程化,所以上面阿寶哥只是簡單羅列了 BetterScroll 在工程化方面使用的開源庫。如果你對 BetterScroll 項目也感興趣的話,可以看看項目中的 package.json 文件,并重點看一下項目中 npm scripts 的配置。



































