我之前写过一个ts单例模式的基类(传从门:实现一个ts单例模式基类(支持代码提示、禁止二次实例化) - 简书 (jianshu.com))。但是经过我思考以后,觉得还有另一种方式创建通用的单例模式。
那么先上代码:
/**
* 单例类的创建器
* @param cls 需要单例化的类
* @example const AClass = singleton(class { ... });
*/
function singleton<T extends { new(...args: any[]): {}, prototype: any }>(cls: T): T & { instance: T["prototype"] } {
// 实例
let instance: any = null;
// 构造函数代理
let constructorProxy: ProxyConstructor | null = null;
const proxy = new Proxy(
cls,
{
construct(target: any, argArray: any[], newTarget: any): T {
if (!instance) {
instance = new cls(...argArray);
// 下面这一行用于替换掉construct函数,减少instance判断,也可以删去这行代码
this.construct = (target: any, argArray: any[], newTarget: any): T => instance;
}
return instance;
},
get(target: T, p: string | symbol, receiver: any): any {
if (p === "instance") {
return new proxy();
}
if (p === "prototype") {
// 用于阻止通过new SampleClass.prototype.constructor()创建新对象
constructorProxy = constructorProxy ?? new Proxy(target[p], {
get(target: any, p: string | symbol, receiver: any): any {
if (p === "constructor") {
return proxy;
}
return target[p];
},
});
return constructorProxy;
}
return target[p];
},
set(target: T, p: string | symbol, newValue: any, receiver: any): boolean {
if (p === "instance") {
return false;
}
target[p] = newValue;
return true;
},
},
);
return proxy as T & { instance: T["prototype"] }; // 这里最好写将proxy的类型转换成函数签名的返回类型(T & { instance: T["prototype"] }),不然在某些环境中可能会出现错误
}
由于我们的singleton
不是类,而是普通的函数,我们在使用的时候就需要传入一个类,并且用一个变量接收返回值。
示例代码:
const SampleClass = singleton(class {
static sampleStaticFunc() {
console.log("sampleStaticFunc");
}
sampleFunc() {
console.log("sampleFunc");
}
});
console.log("new SampleClass() === new SampleClass():", new SampleClass() === new SampleClass());
console.log("SampleClass.instance === new SampleClass():", SampleClass.instance === new SampleClass());
console.log("SampleClass.instance === SampleClass.instance:", SampleClass.instance === SampleClass.instance);
console.log("new (SampleClass.prototype.constructor as typeof SampleClass)() === SampleClass.instance:", new (SampleClass.prototype.constructor as typeof SampleClass)() === SampleClass.instance);
SampleClass.instance.sampleFunc();
SampleClass.sampleStaticFunc();
控制台打印:
new SampleClass() === new SampleClass(): true
SampleClass.instance === new SampleClass(): true
SampleClass.instance === SampleClass.instance: true
new (SampleClass.prototype.constructor as typeof SampleClass)() === SampleClass.instance: true
sampleFunc
sampleStaticFunc
多亏ts类型系统的帮助,我们保留了代码提示的功能与单例模式基类不同的是,本文的方式通过函数调用返回一个代理对象(Proxy)。利用Proxy
我们可以阻止外部直接访问类。
Proxy
的第二个参数对象中可以编写construct
陷阱函数,用于拦截new
操作符,下面是construct的函数签名:
interface ProxyHandler<T extends object> {
/**
* A trap for the `new` operator.
* @param target The original object which is being proxied.
* @param newTarget The constructor that was originally called.
*/
construct?(target: T, argArray: any[], newTarget: Function): object;
// 省略了其他的定义
}
当代理拦截到企图利用new
创建新对象时,如果是第一次实例化,那么允许创建对象;反之返回之前创建的对象。这样可以防止多次实例化:
construct(target: any, argArray: any[], newTarget: any): T {
if (!instance) {
instance = new cls(...argArray);
// 下面这一行用于替换掉construct函数,减少instance判断,也可以删去这行代码
this.construct = (target: any, argArray: any[], newTarget: any): T => instance;
}
return instance;
},
为了支持SampleClass.instance
方式获取实例,我们可以在get
陷阱函数中返回instance
对象。我这里直接使用了new proxy()
,让construct
代替我们返回instance
对象:
get(target: T, p: string | symbol, receiver: any): any {
if (p === "instance") {
return new proxy();
}
return target[p];
}
同时在set
函数中阻止对instance
赋值
set(target: T, p: string | symbol, newValue: any, receiver: any): boolean {
if (p === "instance") {
return false;
}
target[p] = newValue;
return true;
}
以上做法还是不足以完全拦截多次实例化,通过new (SampleClass.prototype.constructor as any)()
还是可以再次创建新对象。那么我们还需要对SampleClass.prototype.constructor
进行代理。做法是将前面提到的get
陷阱函数改成以下代码:
get(target: T, p: string | symbol, receiver: any): any {
if (p === "instance") {
return new proxy();
}
if (p === "prototype") {
// 用于阻止通过new SampleClass.prototype.constructor()创建新对象
// constructorProxy定义在了代理之外、singleton之中,可以参考前面的完整代码
constructorProxy = constructorProxy ?? new Proxy(target[p], {
get(target: any, p: string | symbol, receiver: any): any {
if (p === "constructor") {
return proxy;
}
return target[p];
},
});
return constructorProxy;
}
return target[p];
}
写完了逻辑相关的代码,我们再来写点类型相关的代码。
function singleton<T extends { new(...args: any[]): {}, prototype: any }>(cls: T): T & { instance: T["prototype"] };
对于上面这个函数签名,<T extends { new(...args: any[]): {}, prototype: any }>(cls: T)
表示需要传入的参数需要有构造函数和原型属性,也就是一个类,且不限制构造函数的参数个数和类型。函数的返回值类型首先需要返回cls
类的类型,也就是T
,但是这样ts类型系统无法知道里面有instance
属性,所以这里需要改成交叉类型,而且instance
的类型需要为cls
类的原型,结果就是T & { instance: T["prototype"] }
。简单来说,T
表示了类中有哪些静态属性,而T["prototype"]
表示类中有哪些成员属性。
以上的方法有以下优缺点:
优点:
- 保留了代码提示;
- 依然可以使用
new SampleClass()
,只不过会得到之前创建过的实例; - 可以直接使用
SampleClass.instance
属性获取实例,而不一定得使用SampleClass.getInstance()
方法; - 保留了类唯一一次宝贵的继承机会,不用因为继承单例模式基类而无法继承其他类;
缺点:
- 无法再对构造函数使用
protected
或private
访问限定符; - 使用
SampleClass.instance
的方式获取实例时无法对构造函数进行传参,但是通过new
操作符可以在第一次实例化的时候传参,有可能导致意想不到的问题,建议不要使用构造函数参数; - 使用
const SampleClass = singleton(class { ... });
创建类的方式不太常用,比较奇怪; - IDE不再主动将
SampleClass
当成一个类了,它的类型和在编辑器中的样式将有别于普通的类; -
无法在同一行中使用默认导出了,需要另起一行进行默认导出,影响不大;
- 如果使用
var
的方式定义SampleClass
变量,会产生变量提升的问题,在var
定义之前使用SampleClass
为undefined
。如果用let
或者const
定义就不会有变量提升的问题,会直接报错:error TS2448: Block-scoped variable 'SampleClass' used before its declaration.
。这里我更建议使用const
; - IDE有可能无法实时提示private 成员不可访问和protected 成员不可访问;