Declaration Merging
声明合并指的是编译器将使用相同名称声明的两个独立声明合并为一个定义。合并后的定义具有原始声明的两个特征。可以合并任意数量的声明,不限于只有两个声明。
前提概念
在TypeScript中,声明至少在三个组(命名空间、类型或值)中创建实体。
- 命名空间:创建一个包含以点
.表示法访问的名称的命名空间。 - 类型:创建一个可见类型。
- 值:创建声明会创建在输出的JavaScript中可见的值。
| Declaration Type | Namespace | Class | Enum | Interface | Type Alias | Function | Variable |
|---|---|---|---|---|---|---|---|
| Namespace | X | ||||||
| Type | X | X | X | X | |||
| Value | X | X | X | X | X |
接口合并
最简单、也许是最常见的声明合并类型是接口合并。在最基本的层面上,合并操作将两个声明的成员合并为一个具有相同名称的接口:
interface Box {
height: number;
}
interface Box {
width: number;
}
const rect: Box = {
height: 5,
width: 6
}
接口的非函数成员应该是唯一的,如果不是唯一的,那么也应该有相同的类型。如果非函数成员不唯一且类型不同,那么TypeScript编译器将会报错:
interface Box {
type: string,
}
interface Box {
// Subsequent property declarations must have the same type.
type: number,
}
对于函数成员来说,具有相同名称的每个函数成员被视为描述同一个函数的重载。在接口A与后续接口A进行合并的情况下,第二个接口的优先级高于第一个接口:
interface Cloner {
clone(animal: string): string;
}
interface Cloner {
clone(animal: number): number;
}
interface Cloner {
clone(animal: boolean): boolean;
}
这三个接口将合并为一个单独的声明:
interface Cloner {
clone(animal: boolean): boolean;
clone(animal: number): number;
clone(animal: string): string;
}
每个组的元素保持相同的顺序,但是组本身与后续的重载集合合并时,后续的重载集合会被放在首位。
这种规则的一个例外是特殊化签名。如果一个签名有一个参数,其类型是单个字符串字面量类型(例如,并非是字符串字面量的联合类型),那么它将被移至合并重载列表的顶部:
interface Document {
createElement(tagName: any): Element;
}
interface Document {
createElement(tagName: "span"): HTMLSpanElement;
}
interface Document {
createElement(tagName: string): HTMLElement;
createElement(tagName: "canvas"): HTMLCanvasElement;
}
// merging result
interface Document {
createElement(tagName: "canvas"): HTMLCanvasElement;
createElement(tagName: "span"): HTMLSpanElement;
createElement(tagName: string): HTMLElement;
createElement(tagName: any): Element;
}
命名空间合并
为了合并命名空间,来自每个命名空间中已导出的接口声明的类型定义会被合并,形成一个带有合并接口定义的单个命名空间:
namespace Animals {
export class Zebra {}
}
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Dog {}
}
// merge result
namespace Animals {
export interface Legged {
numberOfLegs: number;
}
export class Zebra {}
export class Dog {}
}
要合并命名空间的值,在每个声明的位置,如果已存在具有相同名称的命名空间,它将通过将第二个命名空间的导出成员添加到第一个命名空间来进一步扩展:
namespace MyFunSpace {
export function myFun() {
console.log('Hello, world! 1');
}
}
namespace MyFunSpace {
export function myFun1() {
console.log('Hello, world! 2');
}
}
// 编译后如下:
// 可以看到结果是在第一个命名空间的基础上进行拓展
var MyFunSpace;
(function (MyFunSpace) {
function myFun() {
console.log('Hello, world! 1');
}
MyFunSpace.myFun = myFun;
})(MyFunSpace || (MyFunSpace = {}));
(function (MyFunSpace) {
function myFun1() {
console.log('Hello, world! 2');
}
MyFunSpace.myFun1 = myFun1;
})(MyFunSpace || (MyFunSpace = {}));
对于非导出成员,其只能在原始(未合并)命名空间中可见。这意味着在合并之后,来自其他声明的合并成员无法访问非导出成员:
namespace Animal {
let haveMuscles = true;
export function animalsHaveMuscles() {
return haveMuscles;
}
}
namespace Animal {
export function doAnimalsHaveMuscles() {
// Error, because haveMuscles is not accessible here
return haveMuscles;
}
}
命名空间在TypeScript中非常灵活,可以与其他类型的声明进行合并。为了实现合并,命名空间声明必须位于将要合并的声明之后。合并后的声明将具有两种声明类型的属性。TypeScript利用这种能力对JavaScript以及其他编程语言中的一些模式进行建模。
命名空间与类合并
这种可见性规则与命名空间合并一样,因此必须导出特定的类型或值。
class Album {
label: Album.AlbumLabel;
}
namespace Album {
export class AlbumLabel {}
}
// 转换结果:
// 在 Album 添加了 AlbumLabel 类
var Album = /** @class */ (function () {
function Album() { }
return Album;
}());
(function (Album) {
var AlbumLabel = /** @class */ (function () {
function AlbumLabel() { }
return AlbumLabel;
}());
Album.AlbumLabel = AlbumLabel;
})(Album || (Album = {}));
最终结果是在另一个类内部管理一个类。
除此之外,还可以使用命名空间在现有类中添加更多的静态成员:
class Album {}
namespace Album {
export class AlbumLabel { }
export const num = 666
}
/*
Album.AlbumLabel = class {}
Album.num = 666
*/
命名空间与函数合并
除了内部类的模式,你可能还熟悉在JavaScript中创建函数,然后通过向函数添加属性来进一步扩展函数的做法。TypeScript使用声明合并的方式以类型安全的方式构建这种定义。
function buildLabel(name: string): string {
return buildLabel.prefix + name + buildLabel.suffix;
}
namespace buildLabel {
export const suffix = "";
export const prefix = "Hello, ";
}
console.log(buildLabel("Sam Smith"));
/*
转换结果:
function buildLabel(name) {
return buildLabel.prefix + name + buildLabel.suffix;
}
(function (buildLabel) {
buildLabel.suffix = "";
buildLabel.prefix = "Hello, ";
})(buildLabel || (buildLabel = {}));
*/
命名空间与枚举合并
类似地,命名空间可以用于向枚举类型添加静态成员。
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;
}
}
}
模块拓展
JavaScript 模块本身不支持像TypeScript命名空间或模块扩展那样的合并功能,但仍然可以通过导入已有对象并进行更新来修补或更新它们。
// observable.ts
export class Observable<T> { }
// map.ts
import { Observable } from "./observable";
Observable.prototype.map = function (f) { };
这种方式在TypeScript中也同样有效,但同时需要使用模块拓展告知编译器相关信息:
// observable.ts
export class Observable<T> {
public value: T[] = [];
constructor(value: T[]) {
this.value = value;
}
}
// map.ts
import { Observable } from "./observable";
declare module "./observable" {
// 使用 interface 拓展 Observable
interface Observable<T> {
map<U>(f: (x: T) => U): U[];
}
}
Observable.prototype.map = function (f) {
return this.value.map(f);
};
const o: Observable<number> = new Observable([1, 2, 3]);
const mapped = o.map(x => x + 1);
console.log(mapped); // [2, 3, 4]
模块名称的解析方式与import/export语句中的模块说明符相同。在模块扩展中,扩展的声明会像在原始模块中声明的一样进行合并。
然而,这种方式也有一些限制:
- 在模块扩展中,不能声明新的顶层声明,只能对现有声明进行修补补丁。
- 在模块扩展中,无法对默认导出进行扩展,只能对具有命名导出的内容进行扩展。这是因为在对导出进行扩展时,需要使用其导出的名称进行增加,而
default是一个保留字,不能用作名称。因此,只有具有命名导出的模块成员才能被扩展。
全局拓展
你也可以在模块内部向全局作用域添加声明:
class Observable<T> { }
declare global {
interface Array<T> {
toObservable(): Observable<T>;
}
}
Array.prototype.toObservable = function () {
return new Observable();
};