许多 TypeScript 团队将 abstract classinterface 视为可以互换的填充物。事实并非如此。选错工具会带来真实的代价:重复的业务逻辑、难以重构的僵化类树,以及可以追溯到某个被误认为安全的基类的微妙运行时错误。解决办法不是死记硬背规则手册,而是学会观察实际需求并选择匹配的工具。

接口是纯粹的契约

接口是一个编译时边界。它描述了一个对象应该是什么样子以及它必须能够做什么,但不包含任何实现。在 TypeScript 编译为 JavaScript 后,接口会完全消失。它不会留下构造函数、原型链,也不会在你的 bundle 中增加任何字节。

想象你正在构建一个通知系统。你定义了一个接口:

interface Notifier {
  send(message: string): void;
  readonly channel: string;
}

任何满足该形状的类,甚至是普通的字面量对象,都是有效的 NotifierEmailNotifier 可以实现它,SmsNotifierSlackNotifier 或你传递给单元测试的 mock 对象也可以。因为接口不携带任何代码,每个实现都需要从头开始编写自己的 send 方法。当实现之间除了公共接口外没有任何共同点时,这正是你想要的效果。

抽象类承载实际代码

抽象类是一个功能完备的类,只是恰好禁止了直接实例化。它可以定义字段、带有逻辑的具体方法以及构造函数逻辑。它强制子类通过抽象方法来填补空白,但同时也为它们提供了无需自行编写的继承行为。

设想一个数据访问层。应用程序中的每个 repository 都需要解析标识符,根据 schema 进行验证,然后才运行特定于存储的查询。接口无法捕获这种共享的执行序列,因为接口不能包含可执行代码。而抽象类可以:

abstract class BaseRepository<T> {
  protected validateId(id: string): boolean {
    return /^[a-z0-9\-]+$/.test(id);
  }

  abstract fetchById(id: string): Promise<T | null>;

  async findById(id: string): Promise<T | null> {
    if (!this.validateId(id)) throw new Error("Invalid ID format");
    return this.fetchById(id);
  }
}

BaseRepository 强制执行结构。它要求子类实现 fetchById,但同时也提供了可运行的逻辑。子类可以免费获得防护栏和样板代码。如果你尝试用接口来替代它,你最终不得不将 validateIdfindById 复制到每一个 repository 中。这不叫抽象,这叫维护成本。

决定选择的唯一问题

你的选择应该始终归结为一个问题:这个契约是否需要携带共享代码?

如果答案是否定的,请使用 interface。如果答案是肯定的,请使用 abstract class。

选错会有直接后果。如果你在只需要“形状”的地方使用了 abstract class,你就会强迫每个实现都进入继承链。单元测试会突然需要尴尬的 mock 或对真实类进行部分 stub。第三方扩展必须继承你的基类,而不能仅仅匹配一个形状。你把一个简单的契约变成了一座大山。

反之,如果你在存在共享行为的地方使用了 interface,你就会在代码库中散布相同逻辑的副本。当该逻辑包含 bug 时,你无法在一个地方修复它,而是必须在十个实现中搜寻,并祈祷自己没有漏掉第十一个。

它们在实践中的实际区别

除了哲学上的分歧,两者之间还有三个实践层面的差距。

性能和 bundle 体积。 接口在编译期间会被擦除。它们不会给你的 JavaScript 输出增加任何权重,也不会消耗运行时内存。抽象类会被编译成真实的构造函数和原型链。你定义的每一个抽象类都会增加 bundle 的大小,并在实例化时占用内存。对于处理数千个实例的服务端,或者需要严格审查的前端 bundle 来说,这种差异并非理论上的。

组合的灵活性。 单个类可以同时实现多个接口。你可能构建一个 FileCache,它在同一时间实现了 CacheDisposableEventEmitter。TypeScript 会对此感到很满意,因为接口不强制要求层级结构。然而,一个类只能继承一个 abstract class。JavaScript 的原型链不支持多重继承。如果你过度依赖抽象类,最终将面临经典的困境:试图合并两个都包含