深入理解 TypeScript 混合(Mixins):用构造函数组合替代多重继承
深入理解 TypeScript 混合(Mixins):用构造函数组合替代多重继承
本文是《TypeScript Deep Dive》中文版 docs/typings/mixins.md 一文的完整展开,系统讲解 TypeScript 与 JavaScript 类只能单继承的约束下,如何借助"混合(Mixin)"这种函数式组合手段,把可重用能力(属性与方法)注入到已有类中。读完本文,你将掌握构造函数类型 Constructor<T> 的写法、两个完整可运行的 Mixin 实现、如何叠加组合多个 Mixin,并理解其与泛型约束、条件类型推断(infer)等底层类型机制的关系,可直接把该模式应用到日志、激活状态、时间戳等横切能力的复用场景中。
单继承的限制:为什么需要混合
TypeScript(以及它编译后的 JavaScript)中的类只支持严格的单继承。这意味着你不能写出下面的多重继承代码:
class User extends Tagged, Timestamped { // ERROR : 不能多重继承
// ..
}
多重继承在多数面向对象语言中都是敏感话题,它带来的菱形继承、成员冲突等问题让 TypeScript 干脆在语法层面直接禁止。但这并不代表我们失去了"把多个可重用组件组合进一个类"的能力。从可重用组件构建类的另一种方式,是通过基类来构建它们,这种模式在 TypeScript 生态中被称为混合(Mixins)。
在阅读本文之前,如果你对类的类型注解、泛型约束等基础概念还不熟悉,可以先浏览 docs/typings/overview.md 中的基本注解、泛型与交叉类型章节,本文的代码会大量使用这些语法。
混合是什么:一个函数约定
混合的核心思想非常简单:采用函数 B 接受一个类 A,并且返回一个带有新功能的类,以此来替代"让 A 扩展 B 来获取 B 上的功能"这一传统继承路径。这里的函数 B 就是混合。
::: tip 「混合」是一个函数:
- 传入一个构造函数;
- 创建一个带有新功能、并且扩展该构造函数的新类;
- 返回这个新类。 :::
也就是说,混合本质上是一个"类工厂"(class factory):输入一个类,输出一个更强大的类。它绕过了 extends 只能写一个父类的语法限制,让多个横切关注点(cross-cutting concerns)可以通过多次函数调用逐层叠加。
一个完整的混合例子
下面给出原文档中最核心的完整示例,它包含两个混合(Timestamped 添加时间戳属性、Activatable 添加激活状态与开关方法),以及将它们组合到基础类 User 上的完整流程:
// 所有 mixins 都需要
type Constructor<T = {}> = new (...args: any[]) => T;
/////////////
// mixins 例子
////////////
// 添加属性的混合例子
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now();
};
}
// 添加属性和方法的混合例子
function Activatable<TBase extends Constructor>(Base: TBase) {
return class extends Base {
isActivated = false;
activate() {
this.isActivated = true;
}
deactivate() {
this.isActivated = false;
}
};
}
///////////
// 组合类
///////////
// 简单的类
class User {
name = '';
}
// 添加 Timestamped 的 User
const TimestampedUser = Timestamped(User);
// 同时添加 Timestamped 和 Activatable 的类
const TimestampedActivatableUser = Timestamped(Activatable(User));
//////////
// 使用组合类
//////////
const timestampedUserExample = new TimestampedUser();
console.log(timestampedUserExample.timestamp);
const timestampedActivatableUserExample = new TimestampedActivatableUser();
console.log(timestampedActivatableUserExample.timestamp);
console.log(timestampedActivatableUserExample.isActivated);
这个例子的运行效果:
new TimestampedUser()的实例同时拥有User的name属性和混合注入的timestamp属性;new TimestampedActivatableUser()的实例则同时拥有name、timestamp、isActivated属性以及activate()/deactivate()方法;- 两个混合可以任意顺序嵌套:
Timestamped(Activatable(User))先把User交给Activatable,再把结果交给Timestamped,最终得到一个叠加了全部能力的新类。
注意:示例代码中的实例字段初始化(如 timestamp = Date.now()、isActivated = false)依赖较新的 ECMAScript 目标(如 target: esnext)或 TS 编译器对类字段的降级处理,实际使用时请根据你的 tsconfig.json 的 target 选项确认编译产物符合运行环境。
下面我们逐步分解这个例子,理解每一段代码背后的类型机制。
创建一个构造函数:Constructor<T> 类型
混合接受一个类,并使用新功能扩展它。因此,第一步是定义一个能够描述"任何类(构造函数)"的类型:
type Constructor<T = {}> = new (...args: any[]) => T;
逐段解读这个类型别名:
new (...args: any[]) => T是 TypeScript 中的构造函数类型(construct signature):它描述一个可以用new调用的函数;...args: any[]表示构造函数的参数可以是任意数量、任意类型,这保证了任何类的构造函数都能满足约束;- 返回值
T是构造出的实例类型; <T = {}>是一个带默认值的泛型参数:当你不显式指定T时,默认值是空对象类型{},因此Constructor与Constructor<{}>等价。
这个类型是整个混合模式的基石:所有混合函数的签名都建立在 TBase extends Constructor 这一约束之上,即"传入的必须是某种构造函数"。
从源码看构造函数类型的另一面:infer 与内置工具类型
Constructor 这种"构造签名"并不是混合模式独有的,它是 TypeScript 类型系统中处理类与实例的核心桥梁。在仓库的 docs/tips/infer.md 一节中,作者对构造函数类型与 infer 的关系做了更深入的展开,其中明确提到构造函数类型可写作:
type Constructor = new (...args: any[]) => any;
并且,当 infer 用于构造函数类型时,可以出现在两个位置:
- 参数位置:
new (...args: infer P) => any; - 返回值位置:
new (...args: any[]) => infer R;
基于这两点,TypeScript 2.8 起内置了两个与构造函数相关的映射类型,它们与混合模式中"传入类、产出实例"的思考方式互为表里:
// 获取构造函数参数类型(即构造函数的参数列表)
type ConstructorParameters<T extends new (...args: any[]) => any> = T extends new (...args: infer P) => any
? P
: never;
// 获取实例类型(即 new 出来的对象类型)
type InstanceType<T extends new (...args: any[]) => any> = T extends new (...args: any[]) => infer R ? R : any;
class TestClass {
constructor(public name: string, public age: number) {}
}
type Params = ConstructorParameters<typeof TestClass>; // [string, number]
type Instance = InstanceType<typeof TestClass>; // TestClass
把这段代码与混合模式对照理解:Timestamped<TBase extends Constructor>(Base: TBase) 中,TBase 就是一个"构造函数类型",而 return class extends Base 返回的匿名类表达式本身也满足 Constructor 的签名。你可以用 InstanceType<TBase> 获取传入类的实例类型,用 ConstructorParameters<TBase> 获取其构造参数列表,从而在混合内部更精细地加工类型信息。
扩展一个类并返回它:混合的本质实现
混合函数的实现体由两部分组成:泛型约束 + 匿名类继承。
// 添加属性的混合例子
function Timestamped<TBase extends Constructor>(Base: TBase) {
return class extends Base {
timestamp = Date.now();
};
}
关键点:
TBase extends Constructor约束了泛型参数必须是构造函数类型,编译器据此允许在return class extends Base中把Base当作父类使用;return class extends Base { ... }返回一个匿名类表达式,它继承传入的类,并追加新的类成员;Timestamped追加了实例属性timestamp,其值在实例化时通过Date.now()取得;Activatable则追加了一个属性isActivated和两个方法activate()/deactivate(),演示了混合既能加状态(属性)也能加行为(方法)。
由于返回的匿名类在结构上"扩展"了 Base,它天然拥有 Base 的所有公开成员,这就是混合能够逐层叠加的机制:每一次函数调用都产生一个新的、更大的类,供下一次调用继续扩展。
组合类与使用:多个混合的叠加
混合真正的威力在于可以无限叠加。在完整示例中我们看到了两种组合粒度:
// 单层混合:User + Timestamped
const TimestampedUser = Timestamped(User);
// 双层混合:User + Activatable + Timestamped(注意嵌套顺序)
const TimestampedActivatableUser = Timestamped(Activatable(User));
组合顺序决定了类成员的"构造"次序:内层的 Activatable(User) 先基于 User 生成一个拥有激活能力的类,外层的 Timestamped 再在其基础上追加时间戳能力。如果顺序反过来,Activatable(Timestamped(User)),最终类在功能上等价,但成员注入的先后次序不同——这在混合之间存在初始化依赖时需要格外注意。
使用层面与普通类完全一致,直接 new 即可,且实例拥有全部叠加成员的类型信息(类型安全由编译器保证):
const timestampedUserExample = new TimestampedUser();
console.log(timestampedUserExample.timestamp);
const timestampedActivatableUserExample = new TimestampedActivatableUser();
console.log(timestampedActivatableUserExample.timestamp);
console.log(timestampedActivatableUserExample.isActivated);
timestampedActivatableUserExample 同时可以调用 activate() 与 deactivate(),并访问 name、timestamp、isActivated 三个属性——这正是单继承语法无法直接表达的"多能力聚合"。
混合与交叉类型、泛型的呼应
如果从类型系统视角审视,混合模式与仓库 docs/typings/overview.md 中讲到的交叉类型(Intersection Types)有异曲同工之妙。交叉类型用 T & U 表达"同时拥有两个对象的所有功能":
function extend<T extends object, U extends object>(first: T, second: U): T & U {
// 把 first 与 second 的属性合并到新对象上
}
混合可以看作"在类层面"的交叉:Timestamped(Activatable(User)) 的产物在结构上近似于 User & Activatable 实例 & Timestamped 实例 的交叉结果,但区别在于混合保留的是真正的 class 形态,可以继续 extends、被 instanceof 识别、被其他混合继续消费。同时,混合函数中反复出现的 TBase extends Constructor 正是泛型约束(Constraints)的典型应用:类型参数 TBase 被约束为构造函数类型,既保证了 Base 能被 extends,又保留了传入类自身的具体类型信息。
实战建议与适用边界
- 何时使用混合:当多个横切能力(时间戳、激活开关、事件能力、可序列化等)需要在不同类之间共享,而它们的继承树互不相关时,混合是最贴合 TypeScript 单继承约束的组合手段;它与仓库 docs/tips/classAreUseful.md 中"用类组织可复用代码"的观点一脉相承——混合正是在类的基础上进一步做水平复用。
- 命名与可读性:混合函数建议以"能力名"命名(如
Timestamped、Activatable),并保持"输入一个类、输出一个类"的纯函数形态,便于嵌套组合与单元测试。 - 注意成员冲突:混合是浅层组合,如果两个混合定义了同名属性或方法,后注入(外层)的会覆盖先注入(内层)的,编译器不会给出多重继承那样的显式错误,设计时需自行规避。
- 类型层面的协作:在混合内部需要更精细的类型加工时,可配合
InstanceType<TBase>、ConstructorParameters<TBase>等内置工具类型(详见 docs/tips/infer.md),获取传入类的实例类型与构造参数,从而写出既有运行时能力、又有完整类型推导的混合。
总而言之,混合是 TypeScript 在"单继承"约束下实现多能力聚合的推荐模式:一个函数、一个泛型约束、一个匿名类继承,三步即可完成从"继承别人的能力"到"给别人注入能力"的转变,让横切逻辑以可组合、可复用、类型安全的方式在类之间自由流动。