TypeScript 在类型层面有一些独特的概念用来描述 JavaScript 中对象的形状。一个比较突出的例子是“声明合并”。理解“声明合并”会对处理现有的 JavaScript 代码有很大帮助,也更有利于理解更多高级的、抽象的概念。
本文中,“声明合并”指的是编译器会将具有相同名字的声明合并为同一个类型定义,合并之后的类型定义具备参与合并的每条声明的特性。同时,声明合并没有数量限制,任意数量的声明都可以合并。
基本概念(Basic Concepts)
在 TypeScript 中,声明用来创建实体。一共有三种实体,分别是:命名空间(namespace)、类型(type)、值(value)。命名空间声明(Namespace-creating declaration)用于创建命名空间,顾名思义,命名空间包含多个命名成员(name),命名空间的成员要通过 .(dotted notation)访问。类型声明(Type-creating declaration)用于创建类型,简单来说类型就是名称和形状的组合。值声明(Value-creating declaration)用于创建值,它们会留存到最终输出的 JavaScript 中。
理解声明合并首先要理解不同的声明语句分别创建了什么东西。
| Declaration Type | Namespace | Type | Value |
|---|---|---|---|
| Namespace | X | X | |
| Class | X | X | |
| Enum | X | X | |
| Interface | X | ||
| Type Alias | X | ||
| Function | X | ||
| Variable | X |
接口合并(Merging Interfaces)
接口合并是最简单、最普遍的声明合并,接口之间的合并机制就是将同名的多个接口的成员(或属性)合并到同一个接口中。
interface Box {height: number;width: number;}interface Box {scale: number;}let box: Box = { height: 5, width: 6, scale: 10 };
对于被合并的多个接口,其中的非函数成员应该是唯一的,如果不唯一,则应该具备相同的类型,如果类型不同,编译器会报错。
接口中的函数成员并不要求唯一,即多个函数可以拥有相同的名称,它们会被当做函数重载。在多个接口进行声明合并的时候,同名的函数成员会合并为一个函数重载。一般情况下,合并的时候,同属一个接口的多个同名函数成员声明顺序并不会发生变化,同时,靠后的接口中的函数成员的优先级会高于靠前的接口中的函数成员的优先级。
interface Cloner {clone(animal: Animal): Animal;}interface Cloner {clone(animal: Sheep): Sheep;}interface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;}// 上述声明会被合并为interface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;clone(animal: Sheep): Sheep;clone(animal: Animal): Animal;}
特殊地,如果被合并的函数的签名具有“单独的”字符串字面量类型,此类声明会冒泡至函数重载声明的顶部。
interface 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;}// 上述声明合并的结果为interface Document {createElement(tagName: "canvas"): HTMLCanvasElement;createElement(tagName: "div"): HTMLDivElement;createElement(tagName: "span"): HTMLSpanElement;createElement(tagName: string): HTMLElement;createElement(tagName: any): Element;}
命名空间合并(Merging Namespaces)
跟接口合并类似,命名空间合并也会合并同名空间的成员。但是,命名空间创建声明会同时创建一个命名空间和一个值,所以命名空间的合并和值的合并应该分别考虑。
命名空间在合并的时候,同名命名空间中导出的同名接口会按照接口合并的规则分别合并,然后命名空间按照类似的规则进行合并,最终得到一个命名唯一的命名空间。
命名空间的值在合并的时候,第一个命名空间的值会被采纳,后续同名的命名空间的值会被合并到第一个命名空间的值中。
namespace Animals {export class Zebra {}}namespace Animals {export interface Legged {numberOfLegs: number;}export class Dog {}}// 合并之后的结果为:namespace Animals {export interface Legged {numberOfLegs: number;}export class Zebra {}export class Dog {}}
此外,我们还需要知道命名空间中非导出成员的合并规则。非导出成员只在原始的命名空间中可见,这意味着,在命名空间合并之后,被合并的成员将无法访问到它依赖的非导出成员。
namespace 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 来说,尽管合并之后它和 haveMuscles 同属于一个命名空间,但由于它原始命名空间与合并后的命名空间并不相同,所以它无法访问到 haveMuscles。
命名空间与类、函数和枚举的合并(Merging Namespaces with Classes, Functions, and Enums)
命名空间非常灵活,它可以和其它类型的声明进行合并。为了达成这一点,被合并的命名空间必需紧跟在目标类型声明之后。合并得到的声明会包含目标类型声明和被合并的命名空间的所有属性。TypeScript 通过这个特性来模拟 JavaScript 中的某些模式。
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel {}
}
成员的可访问性(可见性)规则跟命名空间的规则一致,所以,在上面这个例子中,命名空间中的 AlbumLabel 必需被导出,否则 Album 将无法访问它。合并的结果是一个嵌套的类,后者被前者管理。也可以通过命名空间为目标类添加静态成员。
这种构建嵌套类的方式,非常像我们在 JavaScript 中先创建一个函数,然后为这个函数添加属性来扩展能力的行为。只不过,TypeScript 用一种更加类型安全的方式实现了它。
function buildLabel(name: string): string {
return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
export let suffix = "";
export let prefix = "Hello, ";
}
console.log(buildLabel("Sam Smith"));
类似地,命名空间也可以用来为枚举类型添加静态成员。
enum 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;
}
}
}
不允许的合并(Disallowed Merges)
并不是所有的合并在 TypeScript 中都可以实现。目前,类(classes)无法跟其它类(classes)或者变量(variables)合并。如果需要进行类之间的合并的话,可以看看 Mixins。
模块扩充(Module Augmentation)
尽管 JavaScript 模块不支持合并,但我们可以通过先导入然后再更新的方式来修改模块。
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
// ... another exercise for the reader
};
这一方式在 TypeScript 中也是可行的,但是编译器并不知道 Observable.prototype.map 是什么东西,通过模块扩充(module augmentation)可以将相关信息告诉编译器:
// observable.ts
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.ts
import { 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.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
o.map((x) => x.toFixed());
模块名的解析规则跟 import 和 export 相同(访问 Modules 查看相关信息)。添加必要的类型信息之后,为既有模块添加额外信息的代码就可以正常工作了。
有两条限制需要注意:
- 在模块扩充中不允许声明顶级声明,只能够给已有的声明打补丁。
- 由于
default是保留字,所以默认导出并不能够被扩充,只有命名导出可以被扩充。(关于这一点可以查阅 TypeScript#14080)
全局扩充(Global Augmentation)
在模块内部可以为全局作用域下的其它类型添加扩充声明,规则跟模块级别一致:
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
};
