十六、声明合并

16.1 简介

TypeScript中的一些独特概念在类型层面上描述了JavaScript对象的形状。一个对TypeScript来说特别独特的例子是 “声明合并 “的概念。理解这个概念会让你在处理现有的JavaScript时有一个优势。它还打开了通往更高级抽象概念的大门。

就本文而言,”声明合并 “意味着编译器将两个以相同名称声明的独立声明合并为一个定义。这个合并的定义具有两个原始声明的特征。任何数量的声明都可以被合并;它并不局限于两个声明。

16.2 基本概念

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

Declaration TypeNamespaceTypeValue
NamespaceXX
ClassXX
EnumXX
InterfaceX
Type AliasX
FunctionX
VariableX

了解每个声明所创建的内容将有助于你理解当你执行声明合并时被合并的内容。

16.3 合并接口

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

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

16.4 合并命名空间

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

为了合并命名空间,每个命名空间中声明的导出接口的类型定义本身也被合并,形成一个单一的命名空间,里面有合并的接口定义。

为了合并名字空间的值,在每个声明地点,如果已经存在一个给定名字的名字空间,那么它将被进一步扩展,方法是利用现有的名字空间,将第二个名字空间的导出成员添加到第一个名字空间中。

在这个例子中,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 命名空间的一部分,也不能看到这个未输出的成员。

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

命名空间足够灵活,也可以与其他类型的声明合并。要做到这一点,命名空间声明必须跟在它要合并的声明后面。由此产生的声明具有两种声明类型的属性。TypeScript使用这种能力来模拟JavaScript以及其他编程语言中的一些模式。

16.4.2 将命名空间与类合并

这给了用户一种描述内部类的方法。

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

16.4.3 不被允许的合并

不是所有的合并在TypeScript中都是允许的。目前,类不能与其他类或变量合并。关于模仿类合并的信息,请参阅 Mixins in TypeScript 部分。

16.5 模块增强

虽然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> {
// ... 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 。然后,增量中的声明被合并,就像它们与原始文件在同一个文件中声明一样。

然而,有两个限制需要记住:

  1. 你不能在扩增中声明新的顶层声明–只是对现有声明的补丁。
  2. 默认出口也不能被增强,只能是命名的出口(因为你需要用出口的名字来增强出口,而 default 是一个保留词–详见 #14080 )

16.6 全局增强

你也可以从一个模块内部向全局范围添加声明。

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

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

——高级篇完——

特别声明: 本文转自 古艺散人老师 ,如有需要可前往原文预览查看。