定义合并

状态: 初稿

介绍

TypeScript 一些独特概念在类型层面描述 JavaScript 对象的形体.
这其中尤其特殊的一个便是 TypeScript ‘定义合并’ 的概念.
理解此概念不仅有助于你与现有 JavaScript 代码协作.
它同时打开了通往更高级抽象的大门.

在本章, “定义合并”特指编译器把两个名字相同但各自独立的定义合并到一起.
合并后的定义具有来自两个独立定义的所有功能.
任何数量的定义都能合并; 不限于两个.

基础知识

在 TypeScript 中, 每个声明创建的实体都归于以下三组: 名字空间, 类型, 和值.
名字空间声明创建一个名字空间, 它包含通过点运算符才能访问的标识符.
类型声明创建形体符合声明的类型并绑定到给定名字上.
最后, 值声明创建在输出 JavaScript 可见的值.

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

理解每种声明创建了什么是理解定义合并合并哪些东西的前提.

接口合并

最简单, 可能也是最常见的定义合并是接口合并.
作为最基础的一级, 接口合并机械地把两个接口的成员加入名字相同的单个接口.

1
2
3
4
5
6
7
8
9
10
interface Box {
height: number;
width: number;
}

interface Box {
scale: number;
}

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

各个接口的非函数成员必须唯一.
如果它们不唯一(译注: 名字), 就要求类型一致.
如果两接口声明了名字相同, 但类型不同的非函数成员, 编译器会报错.

对函数成员, 每个名字相同的函数成员都视为是对同一函数重载的描述.
同时值得提醒, 假定接口 A 与后来的 A 合并, 第二个接口优先级更高.

下例体现这点:

1
2
3
4
5
6
7
8
9
10
11
12
interface Cloner {
clone(animal: Animal): Animal;
}

interface Cloner {
clone(animal: Sheep): Sheep;
}

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

这三个接口会合并, 产生如下单一接口声明:

1
2
3
4
5
6
interface Cloner {
clone(animal: Dog): Dog;
clone(animal: Cat): Cat;
clone(animal: Sheep): Sheep;
clone(animal: Animal): Animal;
}

可以看到每个组成员顺序保持不变, 但组本身按照越后(来)的组越靠前的顺序合并.

此规则唯一例外是特化签名.
如果一个签名有单个字符串字面量类型(例如: 不是字符串字面量自适应类型)的参数, 那么这个签名会浮动到合并后的重载签名列表最上方.

举个例子, 以下三个接口会合并:

1
2
3
4
5
6
7
8
9
10
11
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 接口合并后结果如下:

1
2
3
4
5
6
7
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "div"): HTMLDivElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}

名字空间合并

与接口一样, 名字相同的名字空间也会合并它们的成员.
由于名字空间声明同时创建名字空间和值, 我们需要理解两者分别是怎样合并的.

要合并名字空间中的名字空间, 来自在每个名字空间中声明的导出名字空间的类型定义自行合并, 形成一个包含合并后的接口定义的单一名字空间.

要合并名字空间中的值, 在每个声明点, 如果给定名字的名字空间已存在, 它会进一步接收已存在的名字空间, 添加它的导出成员以扩充自身.

下例是名字空间 Animals 的定义合并:

1
2
3
4
5
6
7
8
namespace Animals {
export class Zebra { }
}

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

等同于:

1
2
3
4
5
6
namespace Animals {
export interface Legged { numberOfLegs: number; }

export class Zebra { }
export class Dog { }
}

理解这种名字空间合并模型是一个不错的开始, 但我们有必要了解非导出成员去了哪里.
非导出成员只在源(未经合并的)名字空间可见. 意味着合并以后, 来自其他声明的合并成员不能看见一个源名字空间的非导出成员.

下例更清晰地显示这点:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 和其他语言中的一些模式.

与类合并

与类合并赋予用户以表达内部类的方法.

1
2
3
4
5
6
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel { }
}

合并成员的可见性规则与在’名字空间合并’一节描述的一致, 为了对合并后的类可见, 我们必须导出 AlbumLabel 类.
最终结果是管理内部另一个类的类.
你也可以用名字空间为现有类添加更多静态成员.

除内部类外, 你或许熟悉 JavaScript 创建一个函数然后通过为函数添加属性扩充它的做法.
TypeScript 借助定义合并以类型安全的形式构建这样的定义.

1
2
3
4
5
6
7
8
9
10
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"));

同样, 名字空间可以为枚举添加静态成员:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 中的 Minxin一章.

模块增强

虽然 JavaScript 模块不支持合并, 但你可以通过导入, 更新它们来补充现有对象.
请看这个朴素的 Observable 类:

1
2
3
4
5
6
7
8
9
10
// 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.
你可以利用模块增强告知编译器更多信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 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 模块标识符相同的方式解析.
阅读模块一节获得更多信息.
于是增强中的声明被合并, 如同它们直接声明在源文件中.

模块有两条需牢记的限制:

  1. 你不可以在增强中声明新的顶层声明 — 只能补充现有声明.
  2. 默认导出也不能增强, 只能增强命名导出 (因为导出名对增强来说是不可少的, 而 default 是个关键字 - 查看 #14080 了解细节)

全局增强

你可以在模块内部往全局空间添加声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// observable.ts
export class Observable<T> {
// ... still no implementation ...
}

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

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

全局增强存在与模块增强一样的行为和限制.

如果这篇文章对您有用,可以考虑打赏:)