一、介绍

TypeScript 中有一些独特的概念可以在类型层面上描述 JavaScript 对象的模型,这其中尤为独特的一个例子是"声明合并"的概念。

理解这个概念,有利于操作现有的 JavaScript 代码,同时,也有助于理解更多高级抽象的概念。

“声明合并” 指的是编译器将针对同一个名字的两个独立声明合并为单一声明。合并后的声明同时拥有原先两个声明的特性,任何数量的声明都可以被合并,比局限于两个。

二、基础概念

TypeScript 的声明会创建三种实体之一:namespace 、Type 或者 value,创建 namespace 的声明会新建一个 namespace,包含了用(.)符号来访问时使用的名字。

创建 Type 的声明是:用声明的模型创建一个 class 并绑定到给定的名字上。最后创建 value 的声明会创建在 JavaScript 输出中看到的值。

声明类型NamespaceTypeValue
Namespacex x
Class xx
Enum xx
Interfacex
Type Alias x
Function x
Variable x

理解每个声明创建了什么,有助于理解当声明合并的时候,那些东西被合并了。

三、合并 interface

最简单也是最常见的声明类型是接口合并,从本质上说,合并的机制是把双方的成员放到一个同名的接口中。

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

interface Box {
    scale: number;
}

let b:Box = {height: 5, width: 6, scale: 1};

接口的非函数的成员应该是唯一的。如果它们不是唯一的,则它们必须是同一个类型。如果两个接口中同时声明了同名的非函数成员并且它们的类型不同,则编译器会报错。

对于函数成员,每个同名函数声明都会被当成这个函数的一个重载,需要注意的是,当接口 A 和后来的接口 A 合并时,后面的接口具有更好的优先级。

比如下面的示例:

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

interface Cloner {
    clone(animal: Animal): 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;
}

合并后接口如下:

interface Document {
    createElement(tagName: "canvas"): HTMLCanvasElement;
    createElement(tagName: "div"): HTMLDivElement;
    createElement(tagName: "span"): HTMLSpanElement;
    createElement(tagName: string): HTMLElement;
    createElement(tagName: any): Element;
}

四、合并 namespace

和接口差不多,同名的 namespace 也会合并其成员。namespace 会创建出 namespace 和 value,我们需要知道这两者是怎么合并的。

对于 namespace 的合并,模块导出的同名接口进行合并,构成单一的命名空间内含合并后的接口。

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 中可见。这就是说合并之后,从其他命名空间合并进来的成员无法访问非导出成员。

下面的例子有更清晰的说明:

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 的一部分,但是并不能访问未导出的成员。

五、命名空间与 class 和 function 和 Enum 的合并

命名空间可以与其他类型的声明进行合并。主要命名空间的定义符合将要合并类型的定义。

合并结果包含两者的声明类型。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"));

编译后的结果:

function buildLabel(name) {
    return buildLabel.prefix + name + buildLabel.suffix;
}
(function (buildLabel) {
    buildLabel.suffix = "";
    buildLabel.prefix = "Hello, ";
})(buildLabel || (buildLabel = {}));

console.log(buildLabel("Sam Smith"));

运行结果:

11.jpg

类似的,命名空间可以用来扩展枚举类型:

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

编译结果:

var Color;
(function (Color) {
    Color[Color["red"] = 1] = "red";
    Color[Color["green"] = 2] = "green";
    Color[Color["blue"] = 4] = "blue";
})(Color || (Color = {}));
(function (Color) {
    function mixColor(colorName) {
        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;
        }
    }
    Color.mixColor = mixColor;
})(Color || (Color = {}));

六、非法的合并

TypeScript 并非允许所有的合并。 目前,class 不能与其它 class 或变量合并。

想要了解如何模仿类的合并,需要参考 TypeScript的混入

七、模块扩展

虽然 JavaScript 不支持合并,但是你可以为导入的对象打补丁来更新它们。

// observable.js

export class Observable<T> {
    // ...
}

// 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· 解析模块标识符的方式是一致的。
更多信息请参考 Modules
当这些声明在扩展中合并时,就好像在原始位置被声明了一样。 但是,你不能在扩展中声明新的顶级声明-仅可以扩展模块中已经存在的声明。

八、全局扩展

你也以在模块内部添加声明到全局作用域中。

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

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

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

全局扩展与模块扩展的行为和限制是相同的。