JS 单例模式的实现单例模式简介

单例模式(Singleton Pattern)是最简单的设计模式之一。这种类型的设计模式属于创建型模式,提供了一种创建对象的最佳方式。

特点

  • 意图:保证一个类仅有一个实例,并提供一个访问它的全局访问点。
  • 主要解决:一个全局使用的类频繁地创建与销毁。
  • 何时使用:当您想控制实例数目,节省系统资源的时候。
  • 如何解决:判断系统是否已经有这个单例,如果有则返回,如果没有则创建。

首先对比一下平时不使用单例模式的情况:

在不适用单例模式的情况下,如下,会得到不同的多个实例:

video.js

class Video{    constructor() {        console.log("video created");    }}export { Video };

main.js

import { Video } from "./video.js";const v1 = new Video();const v2 = new Video();console.log(v1 === v2);

控制台输出

方法1:提前构造实例

提前构造一个实例,只向外部暴露该实例的引用,从而实现单例。

但是缺点是需要提前构造实例,而无法做到在需要的时候创建实例。

class Video{    constructor() {        console.log("video created");    }}const v = new Video();export { v };

方法2:构造方法私有化

video.js

class Video{    private constructor() {        console.log("video created");    }    static _ins = null;    static getInstance(){        if(!this._ins){            this._ins = new Video();        }        return this._ins;    }}export { Video };

main.js

import { Video } from "./video.js";const v1 = Video.getInstance();const v2 = Video.getInstance();console.log(v1 === v2);

通过将构造方法私有化,外部无法通过new实例化对象,只能通过静态方法getInstance获取实例。

而在类的内部实现中,使用_ins确保只存在一个实例。

缺点

原生JS不存在private,需要使用TS

JS中,如果剔除private,仅通过getInstance也可以实现单例模式。但是这种方法不严格,无法确保每一个调用者不会使用构造函数创建新的实例。

方法3:通用方法——将任意类转为单例

singleton.js

export function singleton(className){    let ins;    return class{        constructor(...args) {            if(!ins){                ins = new className(...args);            }            return ins;        }    };}
  • 这个函数接收一个类,进行改造之后返回一个新的类;
  • 使用闭包,存储实例对象ins
  • 新的类的构造函数相当于拦截作用:
    • 如果ins不存在,则将传入的参数转交给原来的类的构造函数,并创建一个实例;
    • 如果ins存在,则直接返回存储在闭包中的实例对象。

video.js

import { singleton } from "./singleton.js";class Video{    constructor() {        console.log("video created");    }}const newVideo = singleton(Video);export { newVideo as Video };

main.js

import { Video } from "./video.js";const v1 = new Video();const v2 = new Video();console.log(v1 === v2);

控制台输出

观察到这种实现下,构造函数只被调用了一次,并且v1v2指向同一个实例。

缺点

main.js

Video.prototype.play = function(){    console.log("play");}v1.play(); // Uncaught TypeError: v1.play is not a function

在这个案例中,我们试图在Video的原型上添加一个方法,并通过实例对象v1调用,但是v1所处的原型链上并不能找到这个方法。

再回过头来观察singleton.js的实现:

export function singleton(className){    let ins;    return class{        constructor(...args) {            if(!ins){                ins = new className(...args);            }            return ins;        }    };}
  • main.js中,我们使用的Video类来自于video.js的导出,实际上已经是经过singleton函数改造的类,也就是上面这段代码中,return class {}这个匿名类。
  • 而对于v1v2,它们来自于ins这个实例对象,它由上面这段代码的new className()创建,也就是说它来自于最“简单”的、没有经过单例化的那个Video类。
  • 综上,v1并不是由那个匿名类创建的,所以它们不在同一原型链上。这也是这种单例模式实现方式的缺点,需要改进。

方法4:使用代理

这个方法是对方法3的改进,使用Proxy API对类进行代理,往新的类的原型上添加方法,也会被添加到原来的类的原型上,由此解决了方法3的缺点。

singleton.js

export function singleton(className){    let ins;    return new Proxy(className, {        construct(target, ...args){            if(!ins){                ins = new target(...args);            }            return ins;        }    });}

MDN对于Proxyconstruct更详细的介绍:handler.construct() – JavaScript | MDN (mozilla.org)

这里的constuct主要是拦截外部的new操作,函数参数target指向代理对象,也就是这里的className,即需要被单例化的类。

其它逻辑和方法3一致,使用闭包,通过ins存储实例对象。

video.js

import { singleton } from "./singleton.js";class Video{    constructor() {        console.log("video created");    }}const newVideo = singleton(Video);export { newVideo as Video };

main.js

import { Video } from "./video.js";const v1 = new Video();const v2 = new Video();console.log(v1 === v2);Video.prototype.play = function(){    console.log("play");}v1.play();

控制台输出

观察到构造函数只被调用了一次,并且在单例化的新类的原型上添加方法,实例对象v1也可以访问到。

这是因为newVideo是对Video的代理(这里的命名以video.js为准),在newVideo对象上的操作会被应用在Video这个对象上。

至此,完成了JS中较为完善的单例模式实现。