圖片來源:kalianey.com/前端
本文做者:鄭正和ios
本文以音頻能力中的全局播放爲切入點,探討單例模式在前端業務中的應用。文中代碼均爲 React 組件內代碼。git
在文章一開始,咱們先解釋一下全局播放的含義:github
對大多數具有音頻能力的應用而言,爲了保證音頻體驗上的流暢,全局播放基本是一項必備的能力,很難想象使用一個不具有全局播放能力的應用是種什麼樣的體驗。設想一下,你在聽一首歌的同時不能去瀏覽其餘內容?顯然這是不可接受的。在當前這個時代,即使是視頻,部分應用也已經支持了全局播放(Youtube)。npm
那麼對於前端而言,全局播放又是一個什麼樣的存在呢?雖然前端領域的音視頻能力起步時間較晚,可是當前大量的 Hybrid APP、小程序,或是稍微複雜一些的活動頁,都對全局播放提出了較高的要求,列表增刪,播放模式切換、切歌等等能力都經常被包含在內。小程序
咱們知道,前端裏的 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
方法,封裝了建立和獲取的步驟,由此,使用者不管什麼時候、在應用何處調用該方法,都會獲取到惟一一個音頻實例,對其進行操做,就能夠完成全局播放的邏輯。閉包
在上面的全局播放例子中,咱們能夠注意到音頻實例並無直接暴露給使用者,而是經過一個公有方法 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.getInstance
給 this.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
、全局播放條組件這些類的修改收斂到了一個地方。若是發生了上面隱患一節例子中的修改,咱們只須要在應用側處理 getCollection
和 getInstance
邏輯,對於 Tracks
這些類,它們仍是接收一個 audio
實例,代碼是無須變更的。
本文從音頻播放能力中常見的全局播放提及,進而引伸出了單例模式的討論,最後經過一個單例模式的應用,討論了該模式在實際應用中可能存在的缺陷,並提出瞭解決方法。
本文發佈自 網易雲音樂前端團隊,文章未經受權禁止任何形式的轉載。咱們一直在招人,若是你剛好準備換工做,又剛好喜歡雲音樂,那就 加入咱們!