许多 TypeScript 团队将 abstract class 和 interface 视为可以互换的填充物。事实并非如此。选错工具会带来真实的代价:重复的业务逻辑、难以重构的僵化类树,以及可以追溯到某个被误认为安全的基类的微妙运行时错误。解决办法不是死记硬背规则手册,而是学会观察实际需求并选择匹配的工具。
接口是纯粹的契约
接口是一个编译时边界。它描述了一个对象应该是什么样子以及它必须能够做什么,但不包含任何实现。在 TypeScript 编译为 JavaScript 后,接口会完全消失。它不会留下构造函数、原型链,也不会在你的 bundle 中增加任何字节。
想象你正在构建一个通知系统。你定义了一个接口:
interface Notifier {
send(message: string): void;
readonly channel: string;
}
任何满足该形状的类,甚至是普通的字面量对象,都是有效的 Notifier。EmailNotifier 可以实现它,SmsNotifier、SlackNotifier 或你传递给单元测试的 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,但同时也提供了可运行的逻辑。子类可以免费获得防护栏和样板代码。如果你尝试用接口来替代它,你最终不得不将 validateId 和 findById 复制到每一个 repository 中。这不叫抽象,这叫维护成本。
决定选择的唯一问题
你的选择应该始终归结为一个问题:这个契约是否需要携带共享代码?
如果答案是否定的,请使用 interface。如果答案是肯定的,请使用 abstract class。
选错会有直接后果。如果你在只需要“形状”的地方使用了 abstract class,你就会强迫每个实现都进入继承链。单元测试会突然需要尴尬的 mock 或对真实类进行部分 stub。第三方扩展必须继承你的基类,而不能仅仅匹配一个形状。你把一个简单的契约变成了一座大山。
反之,如果你在存在共享行为的地方使用了 interface,你就会在代码库中散布相同逻辑的副本。当该逻辑包含 bug 时,你无法在一个地方修复它,而是必须在十个实现中搜寻,并祈祷自己没有漏掉第十一个。
它们在实践中的实际区别
除了哲学上的分歧,两者之间还有三个实践层面的差距。
性能和 bundle 体积。 接口在编译期间会被擦除。它们不会给你的 JavaScript 输出增加任何权重,也不会消耗运行时内存。抽象类会被编译成真实的构造函数和原型链。你定义的每一个抽象类都会增加 bundle 的大小,并在实例化时占用内存。对于处理数千个实例的服务端,或者需要严格审查的前端 bundle 来说,这种差异并非理论上的。
组合的灵活性。 单个类可以同时实现多个接口。你可能构建一个 FileCache,它在同一时间实现了 Cache、Disposable 和 EventEmitter。TypeScript 会对此感到很满意,因为接口不强制要求层级结构。然而,一个类只能继承一个 abstract class。JavaScript 的原型链不支持多重继承。如果你过度依赖抽象类,最终将面临经典的困境:试图合并两个都包含
