從全局播放到單例模式

header.png

圖片來源: https://kalianey.com/

本文做者:鄭正和前端

本文以音頻能力中的全局播放爲切入點,探討單例模式在前端業務中的應用。文中代碼均爲 React 組件內代碼。ios

全局播放

在文章一開始,咱們先解釋一下全局播放的含義:git

  1. 媒體在應用中時時都在播放(跨路由、跨 tab、後臺播放)
  2. 用戶對媒體有全局控制能力

對大多數具有音頻能力的應用而言,爲了保證音頻體驗上的流暢,全局播放基本是一項必備的能力,很難想象使用一個不具有全局播放能力的應用是種什麼樣的體驗。設想一下,你在聽一首歌的同時不能去瀏覽其餘內容?顯然這是不可接受的。在當前這個時代,即使是視頻,部分應用也已經支持了全局播放(Youtube)。github

那麼對於前端而言,全局播放又是一個什麼樣的存在呢?雖然前端領域的音視頻能力起步時間較晚,可是當前大量的 Hybrid APP、小程序,或是稍微複雜一些的活動頁,都對全局播放提出了較高的要求,列表增刪,播放模式切換、切歌等等能力都經常被包含在內。npm

咱們知道,前端裏的 Audio 對象已經支持了一部分音頻能力,如自動播放、循環、靜音等能力,但這裏有個問題:前端應用在進行全局播放時,不管當前處於單頁應用(只能是單頁應用,多頁應用暫時不可能作出全局播放)的哪一個子頁面,都必須能且僅能操做同一個音頻對象,不然就不是全局播放了。小程序

所以,咱們有必要對 Audio 作一層封裝,以提供全局播放相關能力,如下代碼對能且僅能操做同一個這一邏輯進行了封裝:設計模式

function singletonAudio = (function () {
    class Audio {
        constructor(options) {
            if (!options.src) throw new Error('播放地址不容許爲空');

            this.audioNode = document.createElement('audio');
            this.audioNode.src = options.src;
            this.audioNode.preload = !!options.preload;
            this.audioNode.autoplay = !!options.autoplay;
            this.audioNode.loop = !!options.loop;
            this.audioNode.muted = !!options.muted;

            // ...
        }

        play(playOptions) {
            // ...
        }

        // 其餘對單個音頻的控制邏輯...
    }

    let audio;

    const _static = {
        getInstance(options) {
            // 若 audio 實例還未被建立,則建立並返回
            if (audio === undefined) {
                audio = new Audio(options);
            }

            return audio;
        }
    };

    return _static;
})();

Audio 類的具體控制邏輯已被省去,由於這不是咱們的重點。這裏咱們採用了一個 IIFE(當即執行函數)來構造閉包,僅返回了一個 _static 對象,該對象提供了 getInstance 方法,封裝了建立獲取的步驟,由此,使用者不管什麼時候、在應用何處調用該方法,都會獲取到惟一一個音頻實例,對其進行操做,就能夠完成全局播放的邏輯。瀏覽器

單例模式(Singleton Pattern)

在上面的全局播放例子中,咱們能夠注意到音頻實例並無直接暴露給使用者,而是經過一個公有方法 getInstance 讓使用者建立、獲取音頻實例。這麼作的目的是禁止使用者主動實例化 Audio,在公共組件的層面上保證全局只存在一個 audio 實例。閉包

如今咱們能夠來看看單例模式的定義了:架構

類僅容許有一個實例,且該實例在用戶側有一個訪問點。

在咱們全局播放的例子中,始終只操做一個 audio 實例,且該實例全局可用。

單例模式的一個常見應用場景(applicability)以下:

實例必須能經過子類的形式進行擴展,且用戶側能在不修改代碼的前提下使用該擴展實例。

光看概念畢竟有點抽象,咱們仍是以實際的場景來講明一下。

仍以上文的 Audio 類爲例,假設單例如今須要提供一個永遠保持循環播放的子類 LoopAudio,代碼修改以下:

function singletonAudio = (function () {
    class Audio {
        // 同上文...
    }

    class LoopAudio extends Audio {
        constructor(options) {
            super(options);
            this.audioNode.loop = true;
        }

        // 其餘對單個音頻的控制邏輯,不開放 loop 屬性的控制方法...
    }

    let audio;

    const _static = {
        getInstance(options) {
            // 若 audio 實例還未被建立,則建立並返回
            if (audio === undefined) {
                if (isLoop()) {
                    audio = new LoopAudio(options);
                } else {
                    audio = new Audio(options);
                }
            }

            return audio;
        }
    };

    return _static;
})();

LoopAudio 類繼承自 Audio 類,強制定義了 loop 屬性,且封閉了 loop 屬性的修改途徑(若 Audio 類已經提供,在 LoopAudio 的同名方法中取消這一行爲)。同時在返回的 _static 對象中,咱們經過 isloop 方法判斷要返回給用戶側哪一種實例,注意這裏的判斷只有第一次會進行,一旦實例建立,就不能再更改了。

你可能要問,爲何搞這麼麻煩?我在 _static 裏從新定義一個方法 getLoopInstance 直接建立/獲取 LoopAudio 類不行嗎?若是你這麼想,請回頭再仔細看看單例模式應用場景的第 2 點後半句,用戶側不修改代碼,即用戶側對 audio 實例擴展爲 loopAudio 實例是無感知的。若是你非要說:我在業務組件裏有些時候須要用 audio 實例,有些時候須要用 loopAudio 實例,那麼,你徹底能夠在業務代碼裏本身對 audio 實例的 loop 屬性進行控制,而這裏就不須要處理這個邏輯了。這種場景和單例模式並不衝突,僅僅是將 loop 屬性的控制權轉移到了用戶側。

這裏咱們舉的 LoopAudio 是單例模式中擴充子類的一個例子,實際應用中擴充的子類可能依賴於一些特定的環境,如根據瀏覽器對 Audio 類的支持程度決定使用原生 Audio 仍是僞造的 DumbAudio,抑或是根據設備性能決定使用高採樣率的 HighQualityAudio 仍是低採樣率的 LowQualityAudio

單例模式的完善

用戶側的例子——音軌

前面提到,全局播放是指同一時間內,應用的全部組件都能操做惟一一個音頻對象,這主要是針對歌曲、視頻成品等內容而言。事實上,對於製做中的歌曲,同時存在多個音軌是很是常見的狀況,若是你用 Pr、Au 等 Adobe 全家桶系列作過音頻剪輯,這個概念你應該很熟悉。

爲了實現音軌這個功能,咱們定義了 Tracks 類:

class Tracks {
    constrcutor() {
        this.tracks = {};
    }

    set(key, options) {
        this.tracks[key] = singletonAudio.getInstance(key, options);
    }

    get(key) {
        return this.tracks[key];
    }

    // 全部音軌音量調節
    volumeUp(options) {
        // 這裏的 options 直接原樣傳入了,實際狀況下可能會對 options 做額外的處理
        // 例如,咱們想調節全部音軌的總體音量,options 傳入 overallVolume
        // 綜合考慮全部 audio 的音量,給每一個 audio 的 volumeUp 方法傳入合適的參數
        Object.keys(this.tracks).forEach((key) => {
            const audio = this.tracks[key];
            audio.volumeUp(options);
        });    
    }
}

在這裏,咱們支持經過實例方法 set 動態新增音軌,但新增的每條音軌,咱們都從 singletonAudio.getInstance 中獲取,這樣咱們能夠保證應用在使用 tracks 實例的 set 方法時,在傳入同樣的 key 的前提下,該 key 若尚未設置 audio 實例,則設置,若是設置過了,就直接返回(這是 singletonAudio.getInstance 自己的特性)。[1]

同時,咱們將 singletonAudio 修改以下:

function singletonAudio = (function () {
    class Audio {
        // 同上文...
    }

    let audios = {};

    const _static = {
        getInstance(key, options) {
            // 若 audio 實例還未被建立,則建立並返回
            if (audios[key] === undefined) {
                audios[key] = new Audio(options);
            }

            return audio[key];
        }
    };

    return _static;
})();

對於這裏對 singletonAudio 的修改,咱們作一些補充說明:

在文章的第二部分,咱們說單例模式下全局播放只有一個 audio 實例,但在這裏的場景下,全局不止一個 audio 實例。事實上,單例模式的定義裏歷來就沒有嚴格限制其只能提供一個實例。這不矛盾麼?

注意看上面這句話的表述中的提供二字,單例模式的確會返回具備單例性質的結構,但單例這一性質體如今這些結構上,單例模式自己徹底能夠返回多個具備單例性質的對象(這是結構的一種)。

This is because it is neither the object or "class" that's returned by a Singleton, it's a structure —— Addy Osmani

好的,解決了爲何這裏會出現多個 audio 實例後,咱們看看以前的表述[1],其中提到 傳入同樣的 key,爲何 key 要同樣呢?有了對於出現多個 audio 實例緣由的補充,這裏解釋起來就方便不少了,key 標識 singletonAudio 返回結構中不一樣的單例,當 key 同樣時,咱們操做的就是同一個單例。

隱患

至此,咱們完成了一個 Tracks 類,它能夠管理多個 audio 實例,每一個 audio 實例自己都具有單例的性質,可是這就沒有問題了嗎?

注意在前面的 tracks 實例的 set 方法中,咱們默認使用了單例模式 singletonAudio,即調用 singletonAudio.getInstancethis.tracks[key] 賦值,這麼作事實上已經有了一個預設,即 this.tracks[key]——也就是某條音軌——一定是由 singletonAudio 建立出來的,這樣一來,Tracks 類就直接與 singletonAudio 綁定了,若是後續 singletonAudio 做了一些修改,Tracks 類只能一塊兒改。舉個例子:

Tracks 類提供了 set 方法:

set(key, options) {
    this.tracks[key] = singletonAudio.getInstance(key, options);
}

這裏咱們經過 key 標識不一樣的音軌,用 options 初始化每條音軌,可是,若是後面咱們的 singletonAudio 發生更改,只提供 getCollection(key) 方法,這裏的 key 用來實例化 Audio 的不一樣子類,該方法返回的對象 collection 再提供原有的 getInstance 方法以獲取該子類下的不一樣單例。這樣一來,原來的 set 方法將會失效。singletonAudio 改動帶動了非業務下游組件(這裏是 Tracks)改動。而相似的狀況有不少,例如全局播放條組件、前端音視頻播放器、本地音視頻採集等等。

因爲 singletonAudio 抽象層級較高(其封裝的是音頻能力,全部涉及音頻能力的非業務下游組件均可能使用到它),後續容易產生大量依賴它的如 Tracks 這樣的非業務下游組件,因爲這些組件自己不承載業務邏輯,咱們也很難事先設計好架構同步 Tracks 類與其餘依賴於 singletonAudio 的修改,此時維護這些下游組件只能一個個修改

不管如何,這種上游組件修改帶動整個用戶側一塊兒做修改的作法,都是極爲不可取的,它會浪費許多沒必要要的時間來對一次更新做兼容,成本太高。

你可能要問,組件特性更新向下兼容,大版本不向下兼容不就能夠了麼?是,但這是在用 npm 管理公共組件的前提下,若是僅僅是單個應用內部的公共組件,還要引入組件版本的概念,未免不太合適。若是爲了這個把應用倉庫改形成 monorepo,又有些小題大作了。

應用側纔是出路?

上述問題之因此存在,就是由於 Tracks 類的寫法耦合了 singletonAudio.getInstance,即上面說的作了 this.tracks[key] 一定由 singletonAudio 建立出來的預設。這是一種很常見的反設計模式:I know where you live,若是一個組件對另外一個組件的瞭解過多,以致於在組件中有大量基於另外一個組件的邏輯,那麼上游組件一旦變更,下游組件除了修改外沒有辦法。組件之間,除了必要的通訊信息,其餘信息應該遵循知道得越少越好的原則。

爲了不上面這種「一次更新,全局修改」的狀況發生,考慮到應用側自己就管理着業務邏輯,咱們不妨把 this.tracks[key] 是否具備單例性質的控制權交給應用側,Tracks 類改寫以下:

class Tracks {
    constrcutor() {
        this.tracks = {};
    }

    set(key, options) {
        this.tracks[key] = options.audio;
    }

    get(key) {
        return this.tracks[key];
    }

    // 全部音軌音量調節
    volumeUp(options) {
        // 同上... 
    }
}

這裏的修改其實很簡單,變更的只有 set 方法,注意到咱們將 options.audio 賦值給了 this.tracks[key],也就是說,某個音軌是否採用上面具備單例性質的 audio 是由實際的業務邏輯決定的,相對於非業務下游組件,業務組件自己的業務上下文使其更容易管理多種、多個像 Tracks 這樣的組件。

在業務側,咱們能夠經過 singletonAudio.getInstance 實例化一個 audio 單例,而後將這個 audio 存儲於頂層 state 中(使用任一狀態管理庫),這樣在全部用到 Tracks 等類的地方,咱們拿到這個全局 audio 做爲依賴注入到 Tracks 類中,此時咱們就把 Tracks、全局播放條組件這些類的修改收斂到了一個地方。若是發生了上面隱患一節例子中的修改,咱們只須要在應用側處理 getCollectiongetInstance 邏輯,對於 Tracks 這些類,它們仍是接收一個 audio 實例,代碼是無須變更的。

小結

本文從音頻播放能力中常見的全局播放提及,進而引伸出了單例模式的討論,最後經過一個單例模式的應用,討論了該模式在實際應用中可能存在的缺陷,並提出瞭解決方法。

參考資料

  • 《JavaScript設計模式》Addy Osmani著
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們
相關文章
相關標籤/搜索