在前面的章节中,我们学习的所有的类属性和方法,都有一个共同的前提:必须先使用 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。
提示: 单例模式是众多设计模式中最常用的一种,并且在实际开发中应用非常广泛,比如封装全局弹窗、状态仓库或网络请求工具等,小伙伴们要尽可能地掌握。
