# 声明合并

# 介绍

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

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

# 基本概念

在 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; // Error, because haveMuscles is not accessible here
  }
}

因为 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 中的 Mixin 部分。

# 模块扩充

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

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

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

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

  • 您不能在扩充中声明新的顶级声明——只是对现有声明的补丁。
  • 默认导出也不能扩展,只能命名导出(因为您需要通过导出名称扩展导出,并且 default是保留字 - 详情请参阅 #14080

# 全局增强

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

// observable.ts
export class Observable<T> {
  // ... still no implementation ...
}

declare global {
  interface Array<T> {
    toObservable(): Observable<T>;
  }
}

Array.prototype.toObservable = function () {
  // ...
};

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

Last Updated: 5/25/2023, 2:35:11 PM