声明合并
引言
TypeScript 中一些独特的概念描述了 JavaScript 对象在类型层面的形状。 其中对 TypeScript 尤为独特的一个概念就是“声明合并”。 理解这个概念将使你在使用现有 JavaScript 时获得优势,同时也为更高级的抽象概念打开了大门。
就本文而言,“声明合并”是指编译器将两个同名的独立声明合并为单个定义。 这个合并后的定义具有两个原始声明的特性。 任意数量的声明都可以合并;不限于仅两个声明。
基本概念
在 TypeScript 中,声明会在命名空间、类型或值这三个组中的至少一个中创建实体。 创建命名空间的声明会创建一个命名空间,其中包含使用点号表示法访问的名称。 创建类型的声明就是:创建一个具有声明形状并绑定到给定名称的类型。 最后,创建值的声明会创建在输出 JavaScript 中可见的值。
| 声明类型 | 命名空间 | 类型 | 值 |
|---|---|---|---|
| 命名空间 | X | X | |
| 类 | X | X | |
| 枚举 | X | X | |
| 接口 | X | ||
| 类型别名 | X | ||
| 函数 | X | ||
| 变量 | X |
理解每个声明创建了什么将有助于你理解在执行声明合并时合并了什么。
合并接口
最简单,或许也是最常见的声明合并类型是接口合并。 在最基本的层面上,合并机制地将两个声明的成员合并到同名的单个接口中。
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = { height: 5, width: 6, scale: 10 };接口的非函数成员应该是唯一的。 如果它们不是唯一的,那么它们的类型必须相同。 如果两个接口声明了同名但不同类型的非函数成员,编译器将发出错误。
对于函数成员,每个同名的函数成员都被视为描述同一个函数的重载。 值得注意的是,在接口 A 与后续的接口 A 合并时,第二个接口的优先级高于第一个。
也就是说,在示例中:
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;
}Document 合并后的声明如下:
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}合并命名空间
与接口类似,同名的命名空间也会合并其成员。 由于命名空间同时创建命名空间和值,我们需要理解两者如何合并。
为了合并命名空间,每个命名空间中声明的导出接口的类型定义本身会被合并,形成一个包含合并接口定义的单一命名空间。
为了合并命名空间的值,在每个声明位置,如果已存在给定名称的命名空间,则通过获取现有命名空间并将第二个命名空间的导出成员添加到第一个来进一步扩展它。
此示例中 Animals 的声明合并:
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; // 错误,因为此处无法访问 haveMuscles
}
}因为 haveMuscles 未被导出,只有共享同一未合并命名空间的 animalsHaveMuscles 函数才能看到该符号。 doAnimalsHaveMuscles 函数,即使它是合并后的 Animal 命名空间的一部分,也无法看到这个未导出的成员。
将命名空间与类、函数和枚举合并
命名空间足够灵活,还可以与其他类型的声明合并。 为此,命名空间声明必须放在它将合并的声明之后。结果声明具有两种声明类型的属性。 TypeScript 利用此能力来模拟 JavaScript 以及其他编程语言中的某些模式。
将命名空间与类合并
这为用户提供了一种描述内部类的方式。
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel {}
}合并成员的可见性规则与合并命名空间一节中描述的相同,因此我们必须导出 AlbumLabel 类,以便合并后的类能看到它。 最终结果是一个在另一个类内部管理的类。 你也可以使用命名空间向现有类添加更多静态成员。
除了内部类的模式之外,你可能还熟悉 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;
}
}
}不允许的合并
并非所有合并都在 TypeScript 中允许。 目前,类不能与其他类或变量合并。 有关模拟类合并的信息,请参阅 TypeScript 中的 Mixins 一节。
模块扩展
尽管 JavaScript 模块不支持合并,但你可以通过导入然后更新现有对象来修补它们。 让我们看一个玩具 Observable 示例:
// observable.ts
export class Observable<T> {
// ... 实现留给读者作为练习 ...
}
// map.ts
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
// ... 另一个留给读者的练习
};这在 TypeScript 中也工作得很好,但编译器不知道 Observable.prototype.map。 你可以使用模块扩展来告知编译器:
// observable.ts
export class Observable<T> {
// ... 实现留给读者作为练习 ...
}
// 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) {
// ... 另一个留给读者的练习
};
// consumer.ts
import { Observable } from "./observable";
import "./map";
let o: Observable<number>;
o.map((x) => x.toFixed());模块名的解析方式与 import/export 中的模块说明符相同。 更多信息请参见模块。 然后扩展中的声明被合并,就好像它们与原始声明在同一文件中声明一样。
但是,有两个限制需要牢记:
- 你不能在扩展中声明新的顶级声明——只能修补现有声明。
- 默认导出也不能被扩展,只能扩展命名导出(因为你需要通过导出的名称来扩展导出,而
default是保留字 - 详情请参见 #14080)
全局扩展
你也可以从模块内部向全局作用域添加声明:
// observable.ts
export class Observable<T> {
// ... 仍然没有实现 ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
};全局扩展具有与模块扩展相同的行为和限制。