- A+
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);
控制台输出
观察到这种实现下,构造函数只被调用了一次,并且v1
和v2
指向同一个实例。
缺点:
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 {}
这个匿名类。 - 而对于
v1
或v2
,它们来自于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对于
Proxy
中construct
更详细的介绍: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
中较为完善的单例模式实现。