图片来源: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
实例,代码是无须变更的。
本文从音频播放能力中常见的全局播放提及,进而引伸出了单例模式的讨论,最后经过一个单例模式的应用,讨论了该模式在实际应用中可能存在的缺陷,并提出了解决方法。
本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们!