从全局播放到单例模式

图片来源:kalianey.com/前端

本文做者:郑正和ios

本文以音频能力中的全局播放为切入点,探讨单例模式在前端业务中的应用。文中代码均为 React 组件内代码。git

全局播放

在文章一开始,咱们先解释一下全局播放的含义:github

  1. 媒体在应用中时时都在播放(跨路由、跨 tab、后台播放)
  2. 用户对媒体有全局控制能力

对大多数具有音频能力的应用而言,为了保证音频体验上的流畅,全局播放基本是一项必备的能力,很难想象使用一个不具有全局播放能力的应用是种什么样的体验。设想一下,你在听一首歌的同时不能去浏览其余内容?显然这是不可接受的。在当前这个时代,即使是视频,部分应用也已经支持了全局播放(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 方法,封装了建立获取的步骤,由此,使用者不管什么时候、在应用何处调用该方法,都会获取到惟一一个音频实例,对其进行操做,就能够完成全局播放的逻辑。闭包

单例模式(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著

本文发布自 网易云音乐前端团队,文章未经受权禁止任何形式的转载。咱们一直在招人,若是你刚好准备换工做,又刚好喜欢云音乐,那就 加入咱们

相关文章
相关标签/搜索