声明融合特性

Declaration Merging

简介

TypeScript中有一些特有概念,它们在类型级别对JavaScript对象外形进行描述(Some of the unique concepts in TypeScript describe the shape of JavaScript objects at the type level)。一个尤其特定于TypeScript的例子,就是“声明融合”这一概念。对此概念的掌握,对于与现有Javascript操作,较有优势。对此概念的掌握,也开启了其它复杂抽象概念的大门。

作为本文的目标,“声明融合”特性,就是指编译器把两个以相同名称进行声明的单独声明,融合为一个单一声明。融合后的声明,有着原先两个声明的特性。任意数目的声明都可被融合;而不受限于仅两个声明。

基本概念

在TypeScript中,一个声明将创建出至少三组之一的实体:命名空间、类型或值。命名空间创建式声明创建出包含可通过使用 点缀符号 进行访问的名称的命名空间。类型创建式声明,则仅完成这些:它们创建出一个对所声明的外形可见,且绑定到给定名称的类型。最后值创建式声明创建的是在输出的JavaScript中可见的数值(In TypeScript, a declaration creates entities in at least one of three groups: namespace, type, or value. Namespace-creating declarations create a namespace, which contains names that are accessed using a dotted notation. Type-creating declarations do just that: they create a type that is visible with the declared shape and bound to the given name. Lastly, value-creating declarations create values that are visible in the output JavaScript)。

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

对各种声明都创建了什么的掌握,有助于理解哪些在执行声明融合时被融合了。

接口的融合(Merging Interfaces)

最简单也是最常见的声明融合类别,要数接口融合。在最基础阶段,这种融合机械地将两个声明的成员,以那个相同的名称结合起来。

interface Box {
    height: number;
    width: number;
}

interface Box {
    scale: number;
}

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

这些接口的非函数成员都应唯一。如出现重复,那么重复的成员应具有相同类型。在接口声明了有相同名称,类型却不一样的非函数成员时,编译器将发出错误。

对于接口中的函数成员,名称相同的各个函数,是以对同一函数的过载进行对待的(For function members, each function member of the same name is treated as describing an overload of the same function)。需要说明的是,在接口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;
}

注意每个分组的元素,保持了同样顺序,而各分组本身则以较后过载集合靠前的顺序被融合的(Notice that the elements of each group maintains the same order, but the groups themselves are merged with later overload sets ordered first)。

这条规则的一个例外,就是特殊签名(specialized signatures)。在某个签名具有类型为 单一 字符串字面值类型(就是说不是字符串字面值的联合)的参数时,那么该函数将被提升到其融合过载清单的顶部(If a signature has a parameter whose type is a single string literal type(e.g. not a union of string literals), then it will be bubbled toward the top of its merged overload list)。

举例来说,下面这些接口将融合到一起:

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

命名空间的融合(Merging Namespaces)

与接口类似,相同名称的命名空间也将对其成员进行融合。因为命名空间同时创建了命名空间与值,所以需要掌握命名空间与值二者是如何进行融合的。

为对命名空间进行融合,来自在各个命名空间中定义的导出接口的类型定义自身被融合,从而形成一个单一的,内部有着这些接口定义的命名空间(To merge the namespaces, type definitions from exported interfaces declared in each namespaces are themselves merged, forming a single namespace with merged interface definitions inside)。

为对命名空间值进行融合,那么在各声明处,如已存在有着给定名称的命名空间,那么其就被通过以取得既有命名空间并将第二个命名空间所导出的成员加入到前一个的方式,被进一步扩展(To merge the namespace value, at each declaration site, if a namespace already exists with the given name, it is further extended by taking the existing namespace and adding the exported members of the second namespace to the first)。

看看下面这个示例中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函数才能看到该符号(the symbol)。而对于doAnimalsHaveMuscles函数,尽管其是融合后的Animal命名空间的一部分,其仍然不能看到这个未导出成员。

命名空间与类、函数及枚举的融合(Merging Namespaces with Classes, Functions, and Enums)

因为命名空间有足够的灵活性,故其可与其它类型的声明进行融合。而要与其它类型的声明融合,命名空间就 必须 位于要与其融合的声明之后。融合得到的声明,将有着所融合声明类型的各自属性。TypeScript利用这种功能,来模仿JavaScript及其它编程语言的某些模式(The resulting declaration has properties of both declaration types. TypeScript uses this capability to model some of the patterns in JavaScript as well as other programming languages)。

命名空间与类的融合(Merging Namespaces with Classes)

这么做给出了一种描述内层类的方式:

class Album {
    label: Album.AlbumLabel;
}

namespace Album {
    export class AlbumLabel {}
}

被融合成员的可见性规则与“命名空间融合”小节中所讲到的相同,因此为让该融合的类AlbumLabel可见,就必须将其导出。融合结果就是在另一个类中进行管理的一个类。还可以使用命名空间,来将更多静态成员加入到既有的类中(The end result is a class managed inside of another class. You can also use namespaces to add more static members to an existing class)。

出了内层类(inner classes)这种模式之外,还有那种建立一个函数并于随后通过往函数上加入属性,来进一步扩展函数的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;
        }
    }
}

不允许的融合(Diallowed Merges)

TypeScript中并非所有融合都是允许的。目前,类就被允许与其它类或变量融合。有关对类融合的模仿,请参考TypeScript中的混入章节。

模块增强(Module Augmentation)

尽管JavaScript模块并不支持融合,但可通过导入并随后对其进行更新,来对既有对象进行补充(Although JavaScript modules do not support merging, you can patch existing objects by importing and then updating them)。看看下面这个Observable的示例:

// observable.js
export class Observable<T> {
    // ... 实现由读者作为练习完成 ...
}

// map.js
import { Observable } from "./observable";

Observable.prototype.map = function (f) {
    // ... 作为读者的另一个练习
}

这种做法在TypeScript中也是可行的,不过编译器并不知道Observable.prototype.map。这里就可以使用模块增强特性,来将其告诉编译器:

// observable.ts 保持不变
// 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) {
    // ... 留给读者的另一个练习
}

// consumer.ts
import { Observable } from "./observable";
import "./map";

let o: Observable<number>;

o.map(x => x.toFixed());

全局增强(Global augmentation)

亦可从某个模块内部,将声明添加到全局作用域(You can also add declarations to the global scope from inside a module)。

// observable.ts
export class Observable<T> {
    // ... 仍旧没有实现 ...
}

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

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

全局增强与模块增强,有着同样的行为和限制(Global augmentations have the same behavior and limits as module augmentations)。

Last change: 2023-03-28, commit: 4e70b88