Declaration Merging
声明合并
介绍
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; // <-- error, haveMuscles is not visible 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, ";
}
alert(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.js
export class Observable<T> {
// ... implementation left as an exercise for the reader ...
}
// map.js
import { Observable } from "./observable";
Observable.prototype.map = function (f) {
// ... another exercise for the reader
}
这也适用于TypeScript,但编译器不知道Observable.prototype.map
。你可以使用模块扩充来告诉编译器:
// observable.ts stays the same
// 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
。有关更多信息,请参阅模块 然后,将扩充中的声明合并,就好像它们是在与原始文件相同的文件中声明的一样。但是,您不能在增强中声明新的顶级声明 - 只是对现有声明的补丁。
全局增强
您还可以从模块内向全局范围添加声明:
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
// ...
}
全局增强与模块增量具有相同的行为和限制。