介绍
TypeScript 中的一些独特概念描述了 JavaScript 对象在类型级别的形状。一个特别独特于 TypeScript 的例子是“声明合并”的概念。理解这个概念将使你在处理现有 JavaScript 时更有优势。它也为更高级的抽象概念打开了大门。
就本文而言,“声明合并”意味着编译器将两个以相同名称声明的独立声明合并成一个定义。这个合并的定义具有两个原始声明的特征。可以合并任意数量的声明;它不限于仅两个声明。
基本概念
在 TypeScript 中,声明至少会创建以下三种类型实体之一:命名空间、类型或值。创建命名空间的声明会创建一个命名空间,该命名空间包含使用点符号访问的名称。创建类型的声明会创建一个具有声明形状并绑定到给定名称的类型。最后,创建值的声明会创建在输出 JavaScript 中可见的值。
声明类型 | 命名空间 | 类型 | 值 |
---|---|---|---|
命名空间 | X | X | |
类 | X | X | |
枚举 | X | X | |
接口 | X | ||
类型别名 | X | ||
函数 | X | ||
变量 | X |
理解每个声明创建的内容将有助于您理解在执行声明合并时合并的内容。
合并接口
最简单,也许也是最常见的声明合并类型是接口合并。在最基本层面上,合并会机械地将两个声明的成员合并到一个具有相同名称的接口中。
ts
interface Box {height: number;width: number;}interface Box {scale: number;}let box: Box = { height: 5, width: 6, scale: 10 };
接口的非函数成员应该是唯一的。如果它们不唯一,则它们必须是相同类型。如果接口声明了相同名称但类型不同的非函数成员,编译器将发出错误。
对于函数成员,每个具有相同名称的函数成员都被视为描述同一个函数的重载。需要注意的是,在接口 A
与后面的接口 A
合并的情况下,第二个接口的优先级高于第一个接口。
也就是说,在以下示例中
ts
interface Cloner {clone(animal: Animal): Animal;}interface Cloner {clone(animal: Sheep): Sheep;}interface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;}
这三个接口将合并以创建一个单一声明,如下所示
ts
interface Cloner {clone(animal: Dog): Dog;clone(animal: Cat): Cat;clone(animal: Sheep): Sheep;clone(animal: Animal): Animal;}
请注意,每个组的元素保持相同的顺序,但组本身是合并的,后面的重载集排在前面。
此规则的一个例外是专门的签名。如果签名有一个参数的类型是 *单个* 字符串字面量类型(例如,不是字符串字面量的联合),那么它将被冒泡到其合并重载列表的顶部。
例如,以下接口将合并在一起
ts
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
的最终合并声明将如下所示
ts
interface Document {createElement(tagName: "canvas"): HTMLCanvasElement;createElement(tagName: "div"): HTMLDivElement;createElement(tagName: "span"): HTMLSpanElement;createElement(tagName: string): HTMLElement;createElement(tagName: any): Element;}
合并命名空间
与接口类似,相同名称的命名空间也会合并其成员。由于命名空间创建了命名空间和值,我们需要了解两者如何合并。
要合并命名空间,每个命名空间中声明的导出接口中的类型定义本身会合并,形成一个具有合并接口定义的单个命名空间。
要合并命名空间值,在每个声明位置,如果已经存在具有给定名称的命名空间,则通过获取现有命名空间并将第二个命名空间的导出成员添加到第一个命名空间来进一步扩展它。
本例中 Animals
的声明合并
ts
namespace Animals {export class Zebra {}}namespace Animals {export interface Legged {numberOfLegs: number;}export class Dog {}}
等同于
ts
namespace Animals {export interface Legged {numberOfLegs: number;}export class Zebra {}export class Dog {}}
这种命名空间合并模型是一个有用的起点,但我们还需要了解非导出成员会发生什么。非导出成员仅在原始(未合并)命名空间中可见。这意味着合并后,来自其他声明的合并成员无法看到非导出成员。
我们可以在这个例子中更清楚地看到这一点
ts
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
函数,即使它属于合并的 Animal
命名空间,也无法看到这个未导出的成员。
将命名空间与类、函数和枚举合并
命名空间足够灵活,可以与其他类型的声明合并。为此,命名空间声明必须紧随其将要合并的声明。生成的声明将具有两种声明类型的属性。TypeScript 使用此功能来模拟 JavaScript 以及其他编程语言中的一些模式。
将命名空间与类合并
这为用户提供了一种描述内部类的方法。
ts
class Album {label: Album.AlbumLabel;}namespace Album {export class AlbumLabel {}}
合并成员的可见性规则与 合并命名空间 部分中描述的相同,因此我们必须导出 AlbumLabel
类,以便合并的类能够看到它。最终结果是在另一个类中管理的类。您也可以使用命名空间向现有类添加更多静态成员。
除了内部类的模式之外,您可能还熟悉 JavaScript 中创建函数然后通过向函数添加属性来进一步扩展函数的做法。TypeScript 使用声明合并以类型安全的方式构建这样的定义。
ts
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"));
类似地,命名空间可用于使用静态成员扩展枚举
ts
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 中的 Mixin 部分。
模块扩展
虽然 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
是一个保留字——有关详细信息,请参阅 #14080)
全局扩展
您还可以从模块内部向全局作用域添加声明
ts
// observable.tsexport class Observable<T> {// ... still no implementation ...}declare global {interface Array<T> {toObservable(): Observable<T>;}}Array.prototype.toObservable = function () {// ...};
全局增强与模块增强具有相同的行为和限制。