深入理解 TypeScript 混合(Mixins):用构造函数组合替代多重继承

原创2026-10-07 23:58:511,400 阅读
文章标签:文档教程

深入理解 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 在"单继承"约束下实现多能力聚合的推荐模式:一个函数、一个泛型约束、一个匿名类继承,三步即可完成从"继承别人的能力"到"给别人注入能力"的转变,让横切逻辑以可组合、可复用、类型安全的方式在类之间自由流动。

登录后查看全文
typescript-book-chinese