TypeScript 静态成员(static)

在前面的章节中,我们学习的所有的类属性和方法,都有一个共同的前提:必须先使用 new 关键字把对象 “实例化” 出来,然后才能通过对象去调用。

比如定义了一个 User 类,里面有个 sayHello() 方法。我们必须先使用 const user = new User() 实例化,然后才能使用 user.sayHello() 来调用。这种依赖于具体实例的成员,我们称之为 “实例成员”。

但在真实的项目开发中,我们经常会遇到一些特殊的功能:比如系统的全局配置项、时间格式化工具函数。它们并不专属于某一个特定的用户,而是整个项目通用的。如果我们每次使用这些工具,都要先 new 一个对象出来,这样写出来的代码不仅啰嗦,而且还会白白浪费内存。

为了解决这个问题,TypeScript 引入了静态成员的概念。

TypeScript 静态成员是什么?

在 TypeScript 中,当我们在类的属性或方法前面加上 static 关键字时,它们就变成了 “静态成员”。

其中,属性前面加上了 static 之后,就变成了 “静态属性”。而方法前面加上了 static 之后,就变成了 “静态方法”。

语法:

class ClassName {
    // 定义静态属性
    访问修饰符 static 属性名: 类型 = 默认值;

    // 定义静态方法
    访问修饰符 static 方法名(): 返回值类型 {
        // 方法体
    }
}

说明:

静态成员最大的特点是:它不属于任何一个实例化的对象,而是直接挂载在 “类” 本身的身上。因此,我们不需要(也不允许)使用实例化对象去调用它,而是应该通过类名来调用,也就是:

类名.属性名
类名.方法名

相信用过原生 JavaScript 的小伙伴都知道,当使用 Math 对象的相关方法(比如 Math.random()),我们是直接就使用 Math.random() 了,从来没有使用 new Math() 来实例化过。

为什么呢?其实是因为 random() 本质上是 Math 类的一个静态方法,而不是实例方法。

示例 1:定义静态属性

class User {
    // 定义实例属性(每个用户独有)
    public name: string;
    
    // 定义静态属性(所有用户共享)
    public static onlineCount: number = 0;

    constructor(name: string) {
        this.name = name;
    }
}

console.log("初始在线人数:", User.onlineCount);

// 修改静态属性
User.onlineCount = 10;
console.log("当前在线人数:", User.onlineCount);

运行结果如下。

初始在线人数:0
当前在线人数:10

分析:

静态属性其实很好理解,它就像是贴在工厂大门上的 “公告牌”。不管是谁路过,只需要看一下工厂大门的 “公告牌” 就可以了,根本不需要进入工厂里面找人去问。在上面的代码中,onlineCount 就是这块公告牌,它是直接挂载在 User 类上的。

需要注意的是,静态属性必须通过 “类名” 去访问,而不能通过 “对象名” 去访问,比如:

// 错误调用:不能通过实例化对象去访问静态属性
const user1 = new User("Jack");
console.log(user1.onlineCount); 
// 报错:属性 “onlineCount” 在类型 “User” 上不存在

TypeScript 静态方法的应用

在真实项目(特别是 Vue 或 React)开发中,static 最重要的应用场景就是:用于封装各种工具类。

比如我们需要一个专门处理日期的工具类,里面包含 “获取当前年份”、“格式化日期”等通用的方法,此时就可以使用静态方法来实现。

示例 2:封装 DateUtils 工具类

class DateUtils {
    // 静态方法:获取当前年份
    public static getCurrentYear(): number {
        const date = new Date();
        return date.getFullYear();
    }

    // 静态方法:补零操作
    public static padZero(num: number): string {
        return num < 10 ? "0" + num : num.toString();
    }
}

// 在项目的任何地方,直接拿来就用
const year = DateUtils.getCurrentYear();
console.log("今年是:", year);

const month = DateUtils.padZero(5);
console.log("格式化后的月份:", month);

运行结果如下。

今年是:2026
格式化后的月份:05

分析:

如果没有借助 static,那么每次想给数字补个零,都需要先实例化一个对象出来,然后再通过这个对象来调用对应的方法,也就是:

const utils = new DateUtils(); 
utils.padZero(5);

这种做法不仅让人抓狂,而且毫无逻辑可言(工具本身没有状态、根本不需要实例化)。而使用了静态方法后,代码的工程化体验直接就拉满了。

TypeScript 静态代码块

在刚才的例子中,我们的静态属性都是直接赋了简单的初始值(比如 userCount = 0)。但在复杂的企业级应用中,有些全局静态配置并不是一开始就写死的,它可能需要经过复杂的计算,或者需要使用 try-catch 解析本地缓存才能得到。

为了处理这种 “复杂的静态初始化逻辑”,TypeScript 引入了非常好用的 “静态代码块”。

示例 3:使用静态代码块初始化配置

class ConfigManager {
    // 只声明,暂时不赋值
    public static config: any;

    // 静态代码块:当类第一次被加载到内存时,会自动执行且只执行一次
    static {
        try {
            // 模拟复杂的初始化逻辑,比如读取并解析本地存储的 JSON
            const savedConfig = '{"timeout": 5000, "theme": "dark"}';
            ConfigManager.config = JSON.parse(savedConfig);
        } catch (error) {
            // 解析失败时,提供默认的保底配置
            ConfigManager.config = { timeout: 3000, theme: "light" };
        }
        console.log("[日志]: 静态配置初始化完成!");
    }
}

// 直接访问,此时静态代码块已经在后台默默执行完毕了
console.log("当前超时时间:", ConfigManager.config.timeout);

运行结果如下。

[日志]: 静态配置初始化完成!
当前超时时间:5000

分析:

在静态代码块内部,我们可以编写任意复杂的逻辑(变量声明、循环、条件判断等)。它就像是专门为 “静态属性” 量身定制的构造函数(constructor)。

TypeScript 静态成员与实例成员的 “隔离”

初学 static 的小伙伴,很容易踩这样一个坑:“在静态方法中,试图使用 this 去访问普通的实例属性。” 实际上,这在 TypeScript 中是绝对被禁止的。

示例 4:静态方法中的 this 陷阱

class User {
    public username: string;
    public static userCount: number = 0;

    constructor(username: string) {
        this.username = username;
        // 每次实例化时,让静态属性自增
        User.userCount++;
    }

    // 这是一个静态方法
    public static printInfo(): void {
        console.log("当前在线人数:", User.userCount);

        // 非常危险的错误!
        // console.log("当前用户的名字是:", this.username);
        // 报错:类型 “typeof User” 上不存在属性 “username”。
    }
}

分析:

console.log("当前用户的名字是:", this.username);

为什么上面这句代码会报错呢?原因其实非常简单:静态方法是直接通过 “类名” 来调用的,这个时候可能根本就还没有任何对象被实例化出来!既然连对象都没有,哪来的 this.name 呢?对吧?

对于静态属性和实例属性,小伙伴们一定要记住以下两条铁律:

  • 在静态方法内部,只能访问其他的静态属性或静态方法。
  • 在普通的实例方法内部,可以直接通过 “类名.静态属性” 来访问静态成员。

TypeScript 实现单例模式

了解了 static 之后,我们就可以轻松拿下一个前端高级架构面试必考题:如何使用 TypeScript 实现一个单例模式?

所谓的 “单例模式”,指的是:一个类在整个项目的生命周期中,只能被实例化出唯一的一个对象。也就是说,不管你在哪里调用、调用多少次,拿到的都是同一个实例。

实际上,像 Vue 中全局的 Vuex / Pinia 状态管理仓库,或者全局的 LocalStorage 管理器,都是使用单例模式来实现的。

TypeScript 实现单例模式的核心原理是:把构造函数私有化(private constructor),然后抛出一个静态方法来暴露这个唯一的实例。

示例 5:使用单例模式

class StorageManager {
    // 定义一个静态属性,用于保存 “唯一的实例”
    private static instance: StorageManager;

    // 将构造函数设为 private,切断外部 new 的可能
    private constructor() {
        console.log("[日志]: 底层存储管理器被初始化了一次");
    }

    // 提供一个对外的静态方法,用于获取唯一实例
    public static getInstance(): StorageManager {
        // 如果实例还不存在,就 new 一个存起来
        if (!StorageManager.instance) {
            StorageManager.instance = new StorageManager();
        }
        // 如果已经存在了,直接把存好的那个返回出去
        return StorageManager.instance;
    }

    public setItem(key: string, value: string): void {
        console.log(`存储数据:${key} = ${value}`);
    }
}

// 只能通过 getInstance() 获取实例
const storage1 = StorageManager.getInstance();
const storage2 = StorageManager.getInstance();

// storage1 和 storage2 其实在内存里是同一个东西
console.log(storage1 === storage2);

storage1.setItem("token", "123456");

运行结果如下。

[日志]: 底层存储管理器被初始化了一次
true
存储数据:token = 123456

分析:

那么,这段代码究竟是如何保证整个项目只有一个实例的呢?我们拆解来看,主要有 3 个核心步骤:

  • 关上大门(私有化构造函数):我们将 constructor() 的访问修饰符设置为了 private。这就意味着,外部代码再也没有办法使用 new StorageManager() 来实例化对象了,从而从根源上切断了创建多个实例的可能。
  • 准备仓库(定义静态属性):我们在类内部定义了一个静态属性 instance。你可以把它看作是一个专属的保险箱,专门用来存放那个 “唯一的实例”。
  • 对外窗口(提供 getInstance() 方法):当第一次调用 getInstance() 时,系统发现 instance 保险箱是空的,于是就在内部 new 了一个对象放进去(此时触发了日志打印)。当第 2 次、第3 次调用时,系统发现保险箱里已经有对象了,就会直接把里面的对象拿出来交给你。

虽然我们调用了 2 次 getInstance(),但实际上底层只执行了一次 new(日志只打印了一次)。storage1 和 storage2 指向的完全是内存中的同一个对象。因此,storage1 === storage2 的结果必然是 true。

提示: 单例模式是众多设计模式中最常用的一种,并且在实际开发中应用非常广泛,比如封装全局弹窗、状态仓库或网络请求工具等,小伙伴们要尽可能地掌握。

给站长反馈

绿叶网正在不断完善中,小伙伴们如果发现任何问题,还望多多给站长反馈,谢谢!

邮箱:lvyenet@vip.qq.com

「绿叶网」服务号
绿叶网服务号放大
关注服务号,微信也能看教程。
绿叶网服务号