简介
TypeScript 中的一些独特概念用于在类型层面描述 JavaScript 对象的形态。其中一个对 TypeScript 而言非常独特的概念是“声明合并”。理解这个概念将在处理现有 JavaScript 代码时为你提供优势,同时它也为你接触更高级的抽象概念打开了大门。
就本文而言,“声明合并”是指编译器将两个使用相同名称声明的独立声明合并为一个单一的定义。这个合并后的定义同时具备了原先两个声明的特性。任意数量的声明都可以合并,不仅限于两个声明。
基本概念
在 TypeScript 中,声明至少会创建以下三类实体中的一种:命名空间(namespace)、类型(type)或值(value)。创建命名空间的声明会生成一个命名空间,其中包含使用点号表示法访问的名称。创建类型的声明顾名思义:它们创建一个可见的、具有声明形态并绑定到给定名称的类型。最后,创建值的声明会生成在输出的 JavaScript 中可见的值。
| 声明类型 | 命名空间 | 类型 | 值 |
|---|---|---|---|
| 命名空间 | X | X | |
| 类 | X | X | |
| 枚举 | X | X | |
| 接口 | X | ||
| 类型别名 | X | ||
| 函数 | X | ||
| 变量 | X |
理解每种声明所创建的内容,将有助于你理解执行声明合并时究竟合并了什么。
合并接口
最简单且可能也是最常见的声明合并类型是接口合并。从最基本的层面来看,这种合并机制会将两个声明的成员连接到一个同名的单一接口中。
tsinterface Box {height: number;width: number;}interface Box {scale: number;}let box: Box = { height: 5, width: 6, scale: 10 };
接口中的非函数成员必须是唯一的。如果它们不唯一,则它们必须具有相同的类型。如果两个接口声明了相同名称但类型不同的非函数成员,编译器将会报错。
对于函数成员,每个同名的函数成员都被视为对同一个函数的重载描述。另外值得注意的是,当接口 A 与后来的接口 A 合并时,第二个接口将具有比第一个更高的优先级。
也就是说,在示例中
tsinterface Cloner {clone(animal: Animal): Animal;}interface Cloner {clone(animal: Sheep): Sheep;}interface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;}
这三个接口将合并为一个单一的声明,如下所示
tsinterface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;clone(animal: Sheep): Sheep;clone(animal: Animal): Animal;}
注意,每一组中的元素保持相同的顺序,但组本身是合并的,后出现的重载集排在前面。
此规则的一个例外是特殊化签名。如果一个签名的参数类型是单一字符串字面量类型(例如,不是字符串字面量的联合类型),那么它将被提升到其合并后的重载列表的最前面。
例如,以下接口将合并在一起
tsinterface Document {createElement(tagName: any): Element;}interface Document {createElement(tagName: "div"): HTMLDivElement;createElement(tagName: "span"): HTMLSpanElement;}interface Document {createElement(tagName: string): HTMLElement;createElement(tagName: "canvas"): HTMLCanvasElement;}
Document 合并后的结果声明将是以下内容
tsinterface Document {createElement(tagName: "canvas"): HTMLCanvasElement;createElement(tagName: "div"): HTMLDivElement;createElement(tagName: "span"): HTMLSpanElement;createElement(tagName: string): HTMLElement;createElement(tagName: any): Element;}
合并命名空间
与接口类似,同名的命名空间也会合并它们的成员。由于命名空间同时创建命名空间和值,我们需要了解两者是如何合并的。
在合并命名空间时,每个命名空间中声明的导出接口的类型定义会进行合并,形成一个包含合并后接口定义的单一命名空间。
在合并命名空间的值时,在每个声明位置,如果已经存在同名的命名空间,则通过获取现有的命名空间并将第二个命名空间的导出成员添加到第一个命名空间中,来对其进行扩展。
在此示例中 Animals 的声明合并
tsnamespace Animals {export class Zebra {}}namespace Animals {export interface Legged {numberOfLegs: number;}export class Dog {}}
等同于
tsnamespace Animals {export interface Legged {numberOfLegs: number;}export class Zebra {}export class Dog {}}
这种命名空间合并模型是一个很好的起点,但我们还需要了解未导出成员会发生什么。未导出的成员仅在原始(未合并)的命名空间内可见。这意味着合并后,来自其他声明的合并成员无法看到未导出的成员。
我们可以在这个示例中更清楚地看到这一点
tsnamespace Animal {let haveMuscles = true;export function animalsHaveMuscles() {return haveMuscles;}}namespace Animal {export function doAnimalsHaveMuscles() {return haveMuscles; // Error, because haveMuscles is not accessible here}}
因为 haveMuscles 未导出,所以只有共享同一个未合并命名空间的 animalsHaveMuscles 函数才能看到该符号。即使 doAnimalsHaveMuscles 函数是合并后的 Animal 命名空间的一部分,也无法看到这个未导出的成员。
合并命名空间与类、函数和枚举
命名空间非常灵活,也可以与其他类型的声明合并。为此,命名空间声明必须位于其要合并的声明之后。合并后的声明同时拥有两种声明类型的属性。TypeScript 利用这一功能来模拟 JavaScript 以及其他编程语言中的某些模式。
合并命名空间与类
这为用户提供了一种描述内部类的方法。
tsclass Album {label: Album.AlbumLabel;}namespace Album {export class AlbumLabel {}}
合并成员的可见性规则与 合并命名空间 一节中描述的相同,因此我们必须导出 AlbumLabel 类,以便合并后的类能够看到它。最终结果是一个在另一个类中管理的类。你也可以使用命名空间向现有类添加更多静态成员。
除了内部类模式外,你可能还熟悉 JavaScript 中创建函数,然后通过向函数添加属性来进一步扩展它的做法。TypeScript 使用声明合并以类型安全的方式构建这类定义。
tsfunction buildLabel(name: string): string {return buildLabel.prefix + name + buildLabel.suffix;}namespace buildLabel {export let suffix = "";export let prefix = "Hello, ";}console.log(buildLabel("Sam Smith"));
同样,命名空间可用于向枚举添加静态成员
tsenum Color {red = 1,green = 2,blue = 4,}namespace Color {export function mixColor(colorName: string) {if (colorName == "yellow") {return Color.red + Color.green;} else if (colorName == "white") {return Color.red + Color.green + Color.blue;} else if (colorName == "magenta") {return Color.red + Color.blue;} else if (colorName == "cyan") {return Color.green + Color.blue;}}}
不允许的合并
并非所有的合并在 TypeScript 中都是允许的。目前,类不能与其他类或变量合并。有关模拟类合并的信息,请参阅 TypeScript 中的 Mixins 一节。
模块扩展(Module Augmentation)
虽然 JavaScript 模块不支持合并,但你可以通过导入现有对象并进行更新来对其进行“修补”。让我们看一个简单的 Observable 示例
ts// observable.tsexport class Observable<T> {// ... implementation left as an exercise for the reader ...}// map.tsimport { Observable } from "./observable";Observable.prototype.map = function (f) {// ... another exercise for the reader};
这在 TypeScript 中也可以正常工作,但编译器不知道 Observable.prototype.map。你可以使用模块扩展来告知编译器
ts// observable.tsexport class Observable<T> {// ... implementation left as an exercise for the reader ...}// map.tsimport { Observable } from "./observable";declare module "./observable" {interface Observable<T> {map<U>(f: (x: T) => U): Observable<U>;}}Observable.prototype.map = function (f) {// ... another exercise for the reader};// consumer.tsimport { Observable } from "./observable";import "./map";let o: Observable<number>;o.map((x) => x.toFixed());
模块名称的解析方式与 import/export 中的模块说明符相同。更多信息请参阅 模块。然后,扩展中的声明将像在原始文件中声明一样进行合并。
然而,需要记住两个限制
- 你不能在扩展中声明新的顶层声明 — 只能对现有声明进行修补。
- 默认导出(default exports)也不能被扩展,只能扩展具名导出(因为你需要按导出的名称来扩展导出,而
default是一个保留字 - 详细信息请参阅 #14080)
全局扩展
你还可以从模块内部向全局作用域添加声明
ts// observable.tsexport class Observable<T> {// ... still no implementation ...}declare global {interface Array<T> {toObservable(): Observable<T>;}}Array.prototype.toObservable = function () {// ...};
全局扩展具有与模块扩展相同的行为和限制。