【TS】另一种实现typescript单例模式的方式(支持代码提示,禁止二次实例化)

我之前写过一个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()方法;
  • 保留了类唯一一次宝贵的继承机会,不用因为继承单例模式基类而无法继承其他类;

缺点:

  • 无法再对构造函数使用protectedprivate访问限定符;
  • 使用SampleClass.instance的方式获取实例时无法对构造函数进行传参,但是通过new操作符可以在第一次实例化的时候传参,有可能导致意想不到的问题,建议不要使用构造函数参数;
  • 使用const SampleClass = singleton(class { ... });创建类的方式不太常用,比较奇怪;
  • IDE不再主动将SampleClass当成一个类了,它的类型和在编辑器中的样式将有别于普通的类;
    单例化的类
    普通的类
  • 无法在同一行中使用默认导出了,需要另起一行进行默认导出,影响不大;
  • 如果使用var的方式定义SampleClass变量,会产生变量提升的问题,在var定义之前使用SampleClassundefined。如果用let或者const定义就不会有变量提升的问题,会直接报错:error TS2448: Block-scoped variable 'SampleClass' used before its declaration.。这里我更建议使用const
  • IDE有可能无法实时提示private 成员不可访问protected 成员不可访问
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容