声明合并

介绍

¥Introduction

TypeScript 中的一些独特概念在类型级别描述了 JavaScript 对象的形状。TypeScript 的一个特别独特的例子是 ‘声明合并’ 的概念。在使用现有 JavaScript 时,理解这个概念会给你带来优势。它还为更高级的抽象概念打开了大门。

¥Some of the unique concepts in TypeScript describe the shape of JavaScript objects at the type level. One example that is especially unique to TypeScript is the concept of ‘declaration merging’. Understanding this concept will give you an advantage when working with existing JavaScript. It also opens the door to more advanced abstraction concepts.

就本文而言,“声明合并” 意味着编译器将使用相同名称声明的两个单独声明合并到一个定义中。这个合并的定义具有两个原始声明的特性。可以合并任意数量的声明;它不仅限于两个声明。

¥For the purposes of this article, “declaration merging” means that the compiler merges two separate declarations declared with the same name into a single definition. This merged definition has the features of both of the original declarations. Any number of declarations can be merged; it’s not limited to just two declarations.

基本概念

¥Basic Concepts

在 TypeScript 中,声明至少在三组中的一组中创建实体:命名空间、类型或值。命名空间创建声明创建一个命名空间,其中包含使用点分符号访问的名称。类型创建声明就是这样做的:他们创建一个类型,该类型在声明的形状中可见并绑定到给定的名称。最后,创建值的声明创建在输出 JavaScript 中可见的值。

¥In TypeScript, a declaration creates entities in at least one of three groups: namespace, type, or value. Namespace-creating declarations create a namespace, which contains names that are accessed using a dotted notation. Type-creating declarations do just that: they create a type that is visible with the declared shape and bound to the given name. Lastly, value-creating declarations create values that are visible in the output JavaScript.

声明类型 命名空间 类型
命名空间 X X
X X
枚举 X X
接口 X
类型别名 X
函数 X
变量 X

了解每个声明创建的内容将帮助你了解执行声明合并时合并的内容。

¥Understanding what is created with each declaration will help you understand what is merged when you perform a declaration merge.

合并接口

¥Merging Interfaces

最简单,也许是最常见的声明合并类型是接口合并。在最基本的层面上,合并将两个声明的成员机械地连接到一个同名的接口中。

¥The simplest, and perhaps most common, type of declaration merging is interface merging. At the most basic level, the merge mechanically joins the members of both declarations into a single interface with the same name.

ts
interface Box {
height: number;
width: number;
}
interface Box {
scale: number;
}
let box: Box = { height: 5, width: 6, scale: 10 };

接口的非函数成员应该是唯一的。如果它们不是唯一的,则它们必须属于同一类型。如果接口都声明了同名但类型不同的非函数成员,编译器将触发错误。

¥Non-function members of the interfaces should be unique. If they are not unique, they must be of the same type. The compiler will issue an error if the interfaces both declare a non-function member of the same name, but of different types.

对于函数成员,每个同名的函数成员都被视为描述同一函数的重载。同样值得注意的是,在接口 A 与后面的接口 A 合并的情况下,第二个接口将具有比第一个更高的优先级。

¥For function members, each function member of the same name is treated as describing an overload of the same function. Of note, too, is that in the case of interface A merging with later interface A, the second interface will have a higher precedence than the first.

也就是说,在示例中:

¥That is, in the example:

ts
interface Cloner {
clone(animal: Animal): Animal;
}
interface Cloner {
clone(animal: Sheep): Sheep;
}
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
}

这三个接口将合并以创建一个声明,如下所示:

¥The three interfaces will merge to create a single declaration as so:

ts
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}

请注意,每个组的元素都保持相同的顺序,但组本身与稍后排序的重载集合并。

¥Notice that the elements of each group maintains the same order, but the groups themselves are merged with later overload sets ordered first.

此规则的一个例外是专用签名。如果签名具有类型为单个字符串字面类型的参数(例如,不是字符串字面的联合),那么它将冒泡到其合并重载列表的顶部。

¥One exception to this rule is specialized signatures. If a signature has a parameter whose type is a single string literal type (e.g. not a union of string literals), then it will be bubbled toward the top of its merged overload list.

例如,以下接口将合并在一起:

¥For instance, the following interfaces will merge together:

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 合并声明如下:

¥The resulting merged declaration of Document will be the following:

ts
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}

合并命名空间

¥Merging Namespaces

与接口类似,同名的命名空间也会合并它们的成员。由于命名空间同时创建命名空间和值,我们需要了解两者如何合并。

¥Similarly to interfaces, namespaces of the same name will also merge their members. Since namespaces create both a namespace and a value, we need to understand how both merge.

为了合并命名空间,来自在每个命名空间中声明的导出接口的类型定义本身被合并,形成一个单一的命名空间,其中包含合并的接口定义。

¥To merge the namespaces, type definitions from exported interfaces declared in each namespace are themselves merged, forming a single namespace with merged interface definitions inside.

为了合并命名空间值,在每个声明站点,如果命名空间已经存在给定名称,则通过采用现有命名空间并将第二个命名空间的导出成员添加到第一个命名空间来进一步扩展它。

¥To merge the namespace value, at each declaration site, if a namespace already exists with the given name, it is further extended by taking the existing namespace and adding the exported members of the second namespace to the first.

本例中 Animals 的声明合并:

¥The declaration merge of Animals in this example:

ts
namespace Animals {
export class Zebra {}
}
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Dog {}
}

相当于:

¥is equivalent to:

ts
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Zebra {}
export class Dog {}
}

这种命名空间合并模型是一个有用的起点,但我们还需要了解非导出成员会发生什么。非导出成员仅在原始(未合并)命名空间中可见。这意味着合并后,来自其他声明的合并成员看不到非导出成员。

¥This model of namespace merging is a helpful starting place, but we also need to understand what happens with non-exported members. Non-exported members are only visible in the original (un-merged) namespace. This means that after merging, merged members that came from other declarations cannot see non-exported members.

在这个例子中我们可以更清楚地看到这一点:

¥We can see this more clearly in this example:

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 命名空间的一部分,也无法看到这个未导出的成员。

¥Because haveMuscles is not exported, only the animalsHaveMuscles function that shares the same un-merged namespace can see the symbol. The doAnimalsHaveMuscles function, even though it’s part of the merged Animal namespace can not see this un-exported member.

将命名空间与类、函数和枚举合并

¥Merging Namespaces with Classes, Functions, and Enums

命名空间足够灵活,也可以与其他类型的声明合并。为此,命名空间声明必须遵循它将合并的声明。生成的声明具有两种声明类型的属性。TypeScript 使用此功能对 JavaScript 和其他编程语言中的一些模式进行建模。

¥Namespaces are flexible enough to also merge with other types of declarations. To do so, the namespace declaration must follow the declaration it will merge with. The resulting declaration has properties of both declaration types. TypeScript uses this capability to model some of the patterns in JavaScript as well as other programming languages.

将命名空间与类合并

¥Merging Namespaces with Classes

这为用户提供了一种描述内部类的方式。

¥This gives the user a way of describing inner classes.

ts
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel {}
}

合并成员的可见性规则与 合并命名空间 部分中描述的相同,因此我们必须导出 AlbumLabel 类以便合并类看到它。最终结果是在另一个类内部管理一个类。你还可以使用命名空间向现有类添加更多静态成员。

¥The visibility rules for merged members is the same as described in the Merging Namespaces section, so we must export the AlbumLabel class for the merged class to see it. The end result is a class managed inside of another class. You can also use namespaces to add more static members to an existing class.

除了内部类的模式,你可能还熟悉创建函数然后通过向函数添加属性来进一步扩展函数的 JavaScript 实践。TypeScript 使用声明合并以类型安全的方式构建这样的定义。

¥In addition to the pattern of inner classes, you may also be familiar with the JavaScript practice of creating a function and then extending the function further by adding properties onto the function. TypeScript uses declaration merging to build up definitions like this in a type-safe way.

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"));

同样,命名空间可用于扩展具有静态成员的枚举:

¥Similarly, namespaces can be used to extend enums with static members:

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;
}
}
}

不允许的合并

¥Disallowed Merges

TypeScript 中并非所有的合并都是允许的。目前,类不能与其他类或变量合并。有关模拟类合并的信息,请参阅 TypeScript 中的混入 部分。

¥Not all merges are allowed in TypeScript. Currently, classes can not merge with other classes or with variables. For information on mimicking class merging, see the Mixins in TypeScript section.

模块扩充

¥Module Augmentation

尽管 JavaScript 模块不支持合并,但你可以通过导入然后更新现有对象来修补它们。让我们看一个玩具 Observable 示例:

¥Although JavaScript modules do not support merging, you can patch existing objects by importing and then updating them. Let’s look at a toy Observable example:

ts
// 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。你可以使用模块扩充来告诉编译器:

¥This works fine in TypeScript too, but the compiler doesn’t know about Observable.prototype.map. You can use module augmentation to tell the compiler about it:

ts
// 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 中的模块说明符相同。有关详细信息,请参阅 模块。然后合并中的声明,就好像它们在与原始文件相同的文件中声明一样。

¥The module name is resolved the same way as module specifiers in import/export. See Modules for more information. Then the declarations in an augmentation are merged as if they were declared in the same file as the original.

但是,有两个限制要记住:

¥However, there are two limitations to keep in mind:

  1. 你不能在扩充中声明新的顶层声明 - 只是对现有声明的补丁。

    ¥You can’t declare new top-level declarations in the augmentation — just patches to existing declarations.

  2. 默认导出也无法增强,只能命名导出(因为你需要通过导出名称来增强导出,并且 default 是保留字 - 有关详细信息,请参阅 #14080

    ¥Default exports also cannot be augmented, only named exports (since you need to augment an export by its exported name, and default is a reserved word - see #14080 for details)

全局增强

¥Global augmentation

你还可以从模块内部向全局作用域添加声明:

¥You can also add declarations to the global scope from inside a module:

ts
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
};

全局增强与模块增强具有相同的行为和限制。

¥Global augmentations have the same behavior and limits as module augmentations.