1. 从JS到TS,先搞清楚我们为什么要折腾
先说点接地气的。JavaScript 里的"类"其实是个挺唬人的东西,很多人学了class关键字就以为懂了面向对象,但真到项目里碰上this丢失、原型链污染、构造函数被当普通函数调用这类问题时,才发现自己只是背了个语法壳子。TS 的出现不是要推翻 JS,而是给这层壳子加上了类型约束和编译期检查,让我们在写代码的阶段就能拦住一大批运行时的低级错误。
这篇文章我打算沿着一条主线讲:先回到 JS 的构造函数和原型链底层逻辑,再看 TS 类语法在类型层面做了哪些增强,最后落到单例模式这个经典设计模式上,把三种思路的选型、实现和坑一次性讲透。适合两类人看:一类是写过 JS 但没用过 TS 的,想搞清楚 TS 的类到底"多了什么";另一类是已经上了 TS 的车,但对构造函数、访问修饰符、单例封装这些细节一直模棱两可的。
举个例子,同样是写一个日志工具类,JS 里你可能随手export default new Logger()完事,但到了 TS 项目里你会开始纠结:这个类要不要implements一个接口?构造函数要不要设为private?getInstance的返回类型标注成什么?这些东西看起来都是小事,但组合起来,决定了你的代码在多人协作和后续维护中到底是帮手还是坑。
这篇文章不是 API 文档的搬运工,我会把每个关键语法点背后的"为什么"讲清楚,再附上我在实际项目中踩过的坑。跟着走一遍,类、构造函数、单例模式这三块基本就焊死了。
2. 先啃硬骨头:JS 构造函数和原型链的原生模样
2.1 new 关键字到底做了什么
很多人以为new只是"创建了一个对象",实际上它背后是四步操作。我直接说结论,再拆开解释。
当执行new Foo()时,JS 引擎会做:
- 创建一个全新的空对象
obj - 把这个空对象的
__proto__指向Foo.prototype - 执行
Foo函数体,同时把this绑定到这个新对象上 - 如果构造函数显式返回了一个对象,就返回那个对象;否则返回
this(也就是第一步创建的对象)
第 2 步是最容易被忽略的。__proto__是实例和构造函数的prototype之间的纽带,它决定了实例能不能通过原型链访问到定义在prototype上的方法。换句话说,你把方法写在构造函数内部和写在prototype上,虽然在调用方式上没区别,但背后的内存模型完全不同。
我见过不少新手在构造函数里写方法,比如:
function Logger(name) { this.name = name; this.log = function (msg) { console.log(`[${this.name}] ${msg}`); }; }这样每一次new Logger()都会创建一份全新的log函数。如果只创建一两个实例无所谓,但如果你在一个循环里创建几百个实例,内存里就多了几百份相同逻辑的函数。正确做法是把共享方法放到原型上:
function Logger(name) { this.name = name; } Logger.prototype.log = function (msg) { console.log(`[${this.name}] ${msg}`); };实例通过原型链去查找log,所有实例共享同一个函数对象。
2.2 prototype 和 constructor 的爱恨纠葛
prototype是函数对象上的一个普通属性,constructor是prototype上的一个指针,反过来指向函数本身。这形成了一个闭环:Foo.prototype.constructor === Foo。
这个闭环很容易被无意间破坏。最常见的就是重写prototype时没有补回constructor:
function Foo() {} Foo.prototype = { bar() { return 1; } }; console.log(Foo.prototype.constructor === Foo); // false这会让依赖constructor做类型判断的代码失效。某些第三方库或工具函数会通过instance.constructor.name去推断对象类型,一旦constructor指错,排查起来让人头疼。
提示:重写
prototype时一定要补上constructor。要么用Object.defineProperty单独修正,要么就避免整体重写,只往现有prototype上逐个添加方法。
2.3 ES6 class 到底是啥
ES6 的class并不是新的对象模型,它只是一个语法糖,底层还是构造函数加原型链那套东西。但它确实做了不少规范化处理:
- 类体中的代码自动处于严格模式
- 类声明不会像普通函数那样被提升(暂时性死区)
- 类方法不可枚举
- 类只能通过
new调用,不能直接作为函数调用
这些都是隐式的保护,但它们其实都不属于类型层面的约束。比如你依然可以这么写:
class Logger { log(msg) { console.log(msg); } } Logger.prototype.log.call(undefined, 'hello'); // 依然能执行因为 JS 是动态语言,方法里的this是什么,取决于调用时的上下文,而不是定义时的上下文。
到这里,JS 侧的核心问题就浮现了:语法上有了"类"的样子,但类型层面完全是开放自由的。你在构造函数里要的是name: string,传一个数字进来,代码不会报警,只有跑到某个依赖name.length或name.toUpperCase()的地方才轰然倒塌。这就是 TS 类要解决的问题。
3. 进入 TS:类的类型系统到底多了什么
3.1 一行一行拆解 TS 类的语法
TS 的类语法在 ES6 的基础上增加了类型标注、访问修饰符、只读属性、参数属性、抽象类等信息。下面这个例子基本覆盖了常用特性:
interface ILogger { log(level: LogLevel, message: string): void; } enum LogLevel { Info = 'INFO', Warn = 'WARN', Error = 'ERROR', } class Logger implements ILogger { private static defaultInstance?: Logger; private readonly name: string; private readonly levels: LogLevel[]; constructor(name: string, levels: LogLevel[] = [LogLevel.Info]) { this.name = name; this.levels = levels; } log(level: LogLevel, message: string): void { if (!this.levels.includes(level)) { return; } console.log(`[${this.name}] [${level}] ${message}`); } static getInstance(): Logger { if (!this.defaultInstance) { this.defaultInstance = new Logger('default'); } return this.defaultInstance; } }逐个来看。implements ILogger是结构化的约束,TS 会检查Logger是否真的实现了接口中的所有成员,方法名、参数个数、返回类型都得匹配。private和readonly是编译期的保护,真到了 JS 运行时,private字段会被编译成普通属性,readonly也会被抹掉。所以记住:TS 的访问控制是帮你在写代码阶段守门,不能替代运行时安全策略。
参数默认值= [LogLevel.Info]会在编译时生成默认赋值逻辑。static getInstance是类级别的静态方法,不依赖实例,可以直接Logger.getInstance()调用。
下面是同一个类的类型标注和运行时状态对比:
| 语法 | 编译前(TS) | 编译后(JS) | 作用阶段 |
|---|---|---|---|
private name: string | 类型检查生效 | 变成普通属性 | 编译期 |
readonly levels: LogLevel[] | 赋值后禁止修改 | 变成普通属性 | 编译期 |
implements ILogger | 结构检查生效 | 完全移除 | 编译期 |
static defaultInstance | 类型标注 | 保留为构造函数属性 | 运行期 |
这种"编译期约束 + 运行期自由"的组合,是理解 TS 类的关键。你写的约束越严格,编译不过的概率就越高,但只要你编译过了,运行时的自由度其实没变——这才是 TS 类的设计哲学。
3.2 参数属性的写法,能省你一大截代码
TS 有一个大多数人没用够的特性:参数属性。它允许你在构造函数参数前面加上访问修饰符或readonly,编译器会自动声明并赋值同名属性,省掉"声明字段 + 手动this.xxx = xxx"的样板代码。
class Person { constructor( private name: string, public age: number, protected email: string, ) {} }等价于:
class Person { private name: string; public age: number; protected email: string; constructor(name: string, age: number, email: string) { this.name = name; this.age = age; this.email = email; } }两种写法编译后的 JS 几乎一样,但参数属性写法明显更紧凑。我个人的使用建议是:简单赋值就用参数属性,需要做额外校验或转换的字段(比如name.trim()、age = Math.max(0, age))就老老实实拆开写,因为参数属性没法在赋值前插入处理逻辑。
这里要区分几个修饰符的意义:
private:当前类内部可访问,实例和子类都不能访问protected:当前类及子类内部可访问,实例不能直接访问public:任何地方都可以访问,默认值就是不写
在 JS 运行时层面这三者最终都没有区别,但在编辑器里你就能感受到private字段被外部引用时报错的那种安全感。
3.3 方法重载和构造器重载
TS 的重载靠的是"声明多个函数签名 + 一个实现签名"的套路。先看函数重载:
function makeMessage(sender: string): string; function makeMessage(sender: string, receiver: string): string; function makeMessage(sender: string, receiver?: string): string { if (receiver) { return `${sender} -> ${receiver}`; } return sender; }调用时的参数类型和个数决定了匹配到哪个重载签名。注意实现签名receiver?必须能兼容前面所有重载,否则编译器会提示"签名不兼容"。
构造器重载的写法也类似,用constructor(...)的实现签名来兼容多种入参模式:
class Logger { constructor(name: string); constructor(name: string, levels: LogLevel[]); constructor(name: string, levels: LogLevel[] = []) { // 统一的实现逻辑 } }这里有个我在项目里踩过的坑:重载不能靠参数类型做运行时分支,TS 的重载是编译时的,到了 JS 运行时,所有重载声明都没了,只留一个实现签名。所以实现签名里必须自己处理"没传第二个参数"这类情况,通常靠可选参数和默认值来解决。
3.4 super 和 this 的调用顺序约束
TS 对super的调用时机非常敏感。在子类构造函数中,访问this之前必须先调用super(),这背后的原因是 JS 原生的继承机制:子类的this是基于父类实例初始化出来的,父类构造函数没跑完,子类实例就是不完整的。
class Base { constructor(public name: string) {} } class Child extends Base { constructor(name: string) { // 这里先访问 this 会报错 console.log(this); // error super(name); console.log(this); // ok } }还有一个细节:如果子类定义了构造函数但忘了调super(),TS 会直接报编译错误;如果你完全没写构造函数,默认构造器会自动调用super(...args)。这个默认行为在多层继承时特别容易踩坑——父类构造函数需要参数,子类不写构造函数反而不会报错,因为默认透传了...args,但类型层面就会失去约束。
4. 单例模式:三种实现方案逐个解剖
4.1 从饿汉到懒汉:单例的经典形态
单例模式的思想很简单:整个程序生命周期内,一个类只有一个实例,外部拿到的永远是同一个对象。经典实现通常分两种:饿汉式和懒汉式。
饿汉式在 JS 里最直接,模块加载时就把实例建好:
class Config { static instance = new Config(); settings = { theme: 'dark' }; } export default Config.instance;懒汉式则是"用到才建":
class Config { static instance = null; static getInstance() { if (!this.instance) { this.instance = new Config(); } return this.instance; } }在 JS/TS 项目里,饿汉式的最大问题是"模块加载顺序"。如果Config.instance = new Config()在模块顶层执行,而这个new Config()的内部逻辑又依赖另外一个尚未初始化完成的模块时,就会碰到 undefined 或者循环依赖的问题。懒汉式把实例化时机推迟到首次调用,通常能避开不少初始化顺序的坑。
4.2 用 TS 严格约束单例:私有构造函数
经典单例在 JS 里有个"看似无解"的漏洞:外部代码能直接new Config()再搞出一个实例,让单例失效。以前我们的应对方式多是约定大于规范——"大家别 new 啊,用getInstance()"。但 TS 给出了真正的硬约束:私有构造函数。
class Config { private static instance: Config | null = null; private constructor( public readonly version: string, ) {} static getInstance(): Config { if (!Config.instance) { Config.instance = new Config('1.0.0'); } return Config.instance; } }在这个版本里,构造函数标了private,外部直接new Config()会得到编译错误:"Constructor of class 'Config' is private and only accessible within the class declaration."。这个约束在编译期就生效,把"不小心 new 出第二个实例"的可能性从工程上掐断了。
这里有个细节值得讲:为什么instance要声明成Config[] | null而不是Config?因为static属性在类型层面是共享的,如果直接private static instance: Config = new Config(),就变成饿汉式了,模块加载即创建实例。懒汉式要求"可能还没创建",所以类型必须是联合类型加上null。
多线程环境下的单例需要考虑同步问题,但 JS 本身是单线程事件循环模型,同一时刻只有一个主线程在执行代码,因此不会有"两个线程同时进入getInstance而创建两个实例"的问题。这一点和 Java/C++ 的并发场景有本质区别,反而让 JS/TS 的懒汉单例天然线程安全。
4.3 真正的现代姿势:模块级单例与依赖注入
TS 的类语法让单例变得"规整",但单例本身在工程上的争议一直存在——它让类的构造函数变成私有,测试时想注入 mock 实例会很别扭。现代前端/Node 项目里,有两种思路正在替代"类单例":
第一种是模块级单例。利用 ES Module 的"每个模块只加载并初始化一次"特性,直接导出一个实例对象:
// config.ts export const config = { version: '1.0.0', theme: 'dark', };更灵活一点,用Object.freeze防止属性被篡改:
// config.ts const config = Object.freeze({ version: '1.0.0', theme: 'dark', }); export default config;模块级单例的优点是简单、天然、零样板代码。缺点是没有类型层面的"唯一性"保证——如果有人再导出new Config(),那就是新的实例,除非搭配私有构造函数一起用。
第二种是依赖注入容器。把一个类的生命周期交给容器管理,容器注册时指定单例或每次新建,业务代码不直接new,而是通过容器获取:
class Container { private instances = new Map<string, unknown>(); register<T>(key: string, factory: () => T, singleton: boolean = true): void { if (singleton && !this.instances.has(key)) { this.instances.set(key, factory()); } } resolve<T>(key: string): T { return this.instances.get(key) as T; } }这种方案解决了单例可测试性差的问题,因为容器里的实例可以被替换。但代价是引入了额外的抽象层。我个人对小项目不推荐上来就用 DI 容器,真要管理全局状态,模块级单例 +Object.freeze通常够用了。
下面用表格对比三种单例方案的取舍:
| 方案 | 类型约束 | 可测试性 | 样板代码量 | 适用场景 |
|---|---|---|---|---|
| 私有构造 + getInstance | 强 | 差(构造函数无法直接 new) | 较多 | 传统类单例的完整体验 |
| 模块级单例导出对象 | 弱(靠约定) | 好(对象可整体替换) | 极少 | 全局配置、常量表 |
| DI 容器管理 | 中 | 好 | 最多 | 中大型项目、依赖复杂 |
4.4 泛型单例,能少写十来个类
如果项目里有很多单例类,一个一个写private static instance + getInstance会很枯燥,而且容易复制粘贴出错。泛型单例可以帮你做一层封装:
class Singleton<T> { private static instances = new Map<string, unknown>(); static getInstance<T>(this: new () => T): T { const key = this.name; if (!Singleton.instances.has(key)) { Singleton.instances.set(key, new this()); } return Singleton.instances.get(key) as T; } } class Logger extends Singleton<Logger> { log(msg: string) { console.log(msg); } } const logger = Logger.getInstance(); const logger2 = Logger.getInstance(); console.log(logger === logger2); // true这里的关键是this: new () => T这个类型标注,它把this约束为"可以被new的构造器类型",同时保持了类型安全性。new this()在运行时就是调用当前子类的构造函数。泛型单例把"每个类都要写的静态字段和查找逻辑"收拢到一个基类里,子类只需要继承就行。
必须提醒一下,这个写法有个隐藏前提:子类构造函数必须是public的,否则new this()编译不过。另外静态字段instances是定义在Singleton上的,所有继承Singleton的子类共享同一个Map,不会出现多个子类实例覆盖的问题,因为key用的是类的name,每个类名不同,存储的键自然分开。
4.5 单例的序列化和多实例漏洞防御
即使有了私有构造函数,单例还有几个边角漏洞值得警惕。
第一个是序列化漏洞。如果单例类重开了toJSON或实现了序列化接口,某些库在深拷贝时还会通过constructor创建新实例,绕开getInstance。JS 的structuredClone和JSON.parse(JSON.stringify(obj))都会让实例的"身份"丢失。
应对思路有两种,看场景取舍。如果单例保存的数据不可变,序列化后回读成一个普通对象也无所谓;如果必须保证引用唯一,最好给单例类加一个恢复逻辑,或者干脆别把单例对象直接传到序列化边界。
第二个是多个运行时副本问题。在微前端或服务端渲染场景下,同一个模块可能被加载进多个独立的 JS 运行时,每个运行时都有自己的"单例",跨运行时的不再是同一个对象。这不是类写法能解决的,得在架构层面处理共享状态,比如用globalThis挂载引用。
第三个是弹窗/工具类单例的视觉冲突。有些项目把全局弹窗实例做成单例,但页面同时打开多个弹窗需求时,单例反而成了限制。我见过的解法是把"弹窗组件"做成动态渲染多个实例,但把"确认框的逻辑配置"做成单例。核心原则是:单例适合管理"资源型"状态(连接池、配置、缓存),不适合管理"多开型"UI(弹窗、抽屉、标签页)。
5. 组合实战:一个受限的日志模块
把构造函数、泛型、单例和访问控制串起来,我设计一个简单的"受限日志模块",它有几个特性:只能通过getInstance()获取唯一实例;实例内部的日志等级集合在初始化后不可变;外部不能访问内部存储结构。
export enum LogLevel { Info = 'INFO', Warn = 'WARN', Error = 'ERROR', } interface Formatter { format(level: LogLevel, message: string): string; } class PlainFormatter implements Formatter { format(level: LogLevel, message: string): string { return `[${level}] ${message}`; } } class TimestampFormatter implements Formatter { format(level: LogLevel, message: string): string { return `[${new Date().toISOString()}] [${level}] ${message}`; } } class Logger { private static instance: Logger | null = null; private readonly levels: Set<LogLevel>; private readonly formatter: Formatter; private constructor(levels: LogLevel[], formatter: Formatter) { this.levels = new Set(levels); this.formatter = formatter; } static getInstance(): Logger { if (!Logger.instance) { Logger.instance = new Logger( [LogLevel.Info, LogLevel.Warn, LogLevel.Error], new TimestampFormatter() ); } return Logger.instance; } log(level: LogLevel, message: string): void { if (!this.levels.has(level)) { return; } console.log(this.formatter.format(level, message)); } } const logger = Logger.getInstance(); logger.log(LogLevel.Error, 'disk full');这个模块的实际可运行逻辑非常简单,但其中有几个设计决策值得品味。
levels声明为readonly Set<LogLevel>只保证"字段引用不可变",不保证Set内部内容不可变。运行时如果有人拿到这个 Set 的引用,依然可以add新的等级。要真正冻结内部数据,得用Object.freeze或只暴露查询方法,不暴露原始 Set。
接口Formatter加了一层策略模式。单例又需要不同格式化行为时,用组合替换继承是更稳的思路。你可以在getInstance的参数上加 formatter 选项,让实例化时选择不同的策略,但一旦实例化完成,formatter 不允许更换——配合private readonly字段达到"初始化后永不再变"的效果。
再扩展一下,如果想让单例支持"初始化参数只允许设置一次",可以做这样的设计:
class Config { private static instance: Config | null = null; private constructor( public readonly theme: string, public readonly version: string, ) {} static init(theme: string, version: string): void { if (Config.instance) { throw new Error('Config already initialized'); } Config.instance = new Config(theme, version); } static getInstance(): Config { if (!Config.instance) { throw new Error('Config not initialized'); } return Config.instance; } } Config.init('dark', '2.0.0'); const config = Config.getInstance();这种"初始化一次 + 之后只读"的模式在脚本启动阶段很实用——启动文件负责init所有全局配置,业务模块只管getInstance读取,不会因为属性在运行中途被改而埋雷。缺点是不能懒加载,必须显式调用init才能用,适合配置本来就来自环境变量或启动参数的场景。
6. 常见问题与坑位速查
把前面零散提到的坑集中整理一下,方便以后排查。
TS 类方法里的 this 在回调中丢失
class Button { private onClick = () => { console.log(this); }; }箭头函数绑定 this 是惯用解法,但要注意:属性被声明为箭头函数时,每个实例都会产生一份新函数,和"原型方法共享一份"的内存模型不同。如果组件实例很多,建议权衡后再用。
static 和 prototype 方法混用的歧义
静态方法里的this指向构造函数本身,而不是实例。别在静态方法里直接访问实例字段,TS 在类型上会拦住,但如果你用了any,这条防线就会失效。
过度的访问修饰符反而难测试
有些人把所有方法都标private,觉得安全,但测试时要么用类型断言硬调用私有方法,要么加// @ts-ignore,要么被迫用any。我建议:对外暴露的 API 用public,需要隐藏的内部工具方法用private或protected,但如果某个"私有方法"非常核心,可以拆成模块内的独立函数导出给测试,而不是硬塞在类里。
重载签名和实现签名长度不一导致的调用歧义
function f(a: string): void; function f(a: number): void; function f(a: string | number): void { console.log(a); }这种情况下调用f('x')和f(1)都没问题,但如果你再加一个参数f(a: string, b?: string),那所有调用点必须匹配全部重载签名,否则编译报错。不建议为了"可能用到"疯狂加重载,先用联合类型,复杂了再升级重载。
单例模块被 tree-shaking 误删
如果某个单例只在启动时被注册、后续都是通过getInstance()获取,那么只要有任何代码调用getInstance(),模块就不会被 tree-shaking 删掉。但如果单例只在init时被调用,而init本身没被直接引用,模块可能在打包时被优化掉。稳妥做法是入口文件显式引入一次,比如import './config';。
Object.freeze 不等于深冻结
Object.freeze只冻结顶层属性。如果单例对象内部还有数组或子对象,这些子结构依然可变。需要真正不可变时,要么递归冻,要么用只读类型 + 运行时防御。
类型断言 vs 类型守卫
有人喜欢在单例获取处直接as Config绕过判空,这在运行时可能返回undefined然后报错。更稳妥的是用类型守卫:
function assertConfig(config: Config | null): asserts config is Config { if (!config) { throw new Error('Config not initialized'); } }这样调用处之后的代码里,config的类型就会被收窄为Config,不用再打问号。
7. 收尾的几个个人体会
类、构造函数和单例模式,看起来是三个独立的知识点,但它们其实是同一根主线上的不同切面。没有理解 JS 构造函数和原型链的真相,TS 类就只是"长得像 Java 的语法糖";没有私有构造函数这个硬约束,单例就只是"约定大家别 new"的软规范;没有对编译期约束和运行时自由的清醒认识,很快会在类型断言和树摇等细节上栽跟头。
我个人在实际项目里的体会是:能用模块级单例解决的问题,就不要硬套类单例;能用readonly + Object.freeze守住的数据,就不要依赖别人"自觉不改"。类型系统是设计意图的载体,写完编译不过的代码不一定差,但那些"编译能过、运行时炸锅"的边界条件,才是真正值得花时间研究的对象。
最后分享一个小技巧:在 TS 里调试单例类时,可以在构造函数里加一行console.trace看看实例是在哪里被创建的——如果getInstance()被调用了很多次,trace 能帮你快速定位上一次实例化的调用栈,排查"单例被意外重建"的问题比想象中常见。多写几个不同形态的单例,踩过几次序列化和模块加载顺序的坑之后,这些东西就彻底长在肌肉记忆里了。