命名空间

在 ECMAScript 2015 之前,JavaScript 语言没有内置的模块支持。在 JavaScript 程序中,通常使用 命名空间 来组织并隔离代码以免产生命名冲突等问题。最为流行的实现命名空间的方法是使用立即执行的函数表达式。这是因为立即执行的函数表达式能够创建出一个新的作用域并且不会对外层作用域产生影响。

下例中,使用立即执行的函数表达式定义了两个命名空间,在这两个命名空间中定义的变量 x 不会相互冲突。示例如下:

(function() {

    const x = 0;

})();

(function() {

    const x = { message: 'hello world' };

})();

TypeScript 利用了这个经典的命名空间实现方式并提供了声明命名空间的简便语法。

命名空间声明

命名空间通过 namespace 关键字来声明,它相当于一种语法糖。示例如下:

namespace Utils {

    function isString(value: any) {
        return typeof value === 'string';
    }

}

此例中,我们声明了一个名为 Utils 的命名空间。这段 TypeScript 代码在编译后将生成如下 JavaScript 代码:

// output.js

"use strict";
var Utils;
(function (Utils) {
    function isString(value) {
        return typeof value === 'string';
    }
})(Utils || (Utils = {}));

我们能够看到命名空间被转换成了立即执行的函数表达式。

在定义命名空间的名字时允许使用以点符号 . 分隔的名字,这与其他编程语言中的命名空间声明类似。示例如下:

namespace System.Utils {
    function isString(value: any) {
        return typeof value === 'string';
    }
}

此例中定义的命名空间相当于两个嵌套的命名空间声明,它等同于如下的代码:

namespace System {
    export namespace Utils {
        function isString(value: any) {
            return typeof value === 'string';
        }
    }
}

在命名空间内部可以使用绝大多数语言功能,如变量声明、函数声明、接口声明和命名空间声明等。示例如下:

namespace Outer {
    namespace Inner {
        const a = 0;

        type Nullable<T> = T | undefined | null;

        interface Point {
            x: number;
            y: number;
        }

        class Cat {
            name: string;
        }

        function f(p: Point) {
            console.log(p.x);
        }
    }
}

导出命名空间内的声明

默认情况下,在命名空间内部的声明只允许在该命名空间内部使用,在命名空间之外访问命名空间内部的声明会产生错误。示例如下:

namespace Utils {
    function isString(value: any) {
        return typeof value === 'string';
    }

    // 正确
    isString('yes');
}

Utils.isString('no');
//    ~~~~~~~~
//    编译错误!Utils中不存在isString属性

如果我们查看由此例中的 TypeScript 代码生成的 JavaScript 代码,那么就能够明白为什么这段代码会产生错误,示例如下:

// output.js

var Utils;
(function (Utils) {

    function isString(value) {
        return typeof value === 'string';
    }

    isString('yes');

})(Utils || (Utils = {}));

Utils.isString('no'); // 运行错误

通过分析生成的 JavaScript 代码能够发现 isString 仅存在于立即执行的函数表达式的内部作用域,在外部作用域不允许访问内部作用域中的声明。

如果想要让命名空间内部的某个声明在命名空间外部也能够使用,则需要使用导出声明语句明确地导出该声明。导出命名空间内的声明需要使用 export 关键字,示例如下:

namespace Utils {
    export function isString(value: any) {
        return typeof value === 'string';
    }

    // 正确
    isString('yes');
}

// 正确
Utils.isString('yes');

此例中,我们使用 export 关键字导出了 isString 函数声明。因此,在 Utils 外部也可以使用 isString 函数。此例中的代码生成的 JavaScript 代码如下所示:

// output.js

var Utils;
(function (Utils) {
    function isString(value) {
        return typeof value === 'string';
    }

    Utils.isString = isString;

    isString('yes');

})(Utils || (Utils = {}));

Utils.isString('yes');

在访问导出的命名空间声明时,需要使用命名空间名和导出声明名并用点符号连接,这类似于对象属性访问的语法。

别名导入声明

我们可以使用 import 语句为命名空间的导出声明起一个别名。当命名空间名字比较长时,使用别名能够有效地简化代码。示例如下:

namespace Utils {
    export function isString(value: any) {
        return typeof value === 'string';
    }
}

namespace App {
    import isString = Utils.isString;

    isString('yes');

    Utils.isString('yes');
}

此例中,在 App 命名空间中为从 Utils 命名空间中导出的 isString 函数声明设置了一个别名 isString,这样就可以像第 10 行一样使用 isString 来引用 Utils.isString 函数,而不必像第 12 行那样写出完整的访问路径。

别名导入本质上 相当于 新声明了一个变量并将导出声明赋值给该变量。例如,上例中的代码编译后生成的 JavaScript 代码如下所示:

"use strict";
var Utils;
(function (Utils) {
    function isString(value) {
        return typeof value === 'string';
    }
    Utils.isString = isString;
})(Utils || (Utils = {}));
var App;
(function (App) {
    var isString = Utils.isString;  // 别名导入声明
    isString('yes');
    Utils.isString('yes');
})(App || (App = {}));

需要注意的是,别名导入只是相当于新声明了一个变量而已,实际上不完全是这样的,因为别名导入对类型也有效。示例如下:

namespace Utils {
    export interface Point {
        x: number;
        y: number;
    }
}

namespace App {
    import Point = Utils.Point;

    const p: Point = { x: 0, y: 0 };
}

此例中的代码编译后生成的 JavaScript 代码如下所示:

// output.js

"use strict";
var App;
(function (App) {
    const p = { x: 0, y: 0 };
})(App || (App = {}));

在多文件中使用命名空间

在实际工程中,代码不可能都放在同一个文件中,一定会拆分到不同的源代码文件。我们也可以将同一个命名空间声明拆分到不同的文件中,TypeScript 最终会将同名的命名空间声明合并在一起。例如,在如下两个文件中声明了同名的命名空间。

a.ts 文件的内容如下:

namespace Utils {
    export function isString(value: any) {
        return typeof value === 'string';
    }

    export interface Point {
        x: number;
        y: number;
    }
}

b.ts 文件的内容如下:

namespace Utils {
    export function isNumber(value: any) {
        return typeof value === 'number';
    }
}

最终,合并后的 Utils 命名空间中存在三个导出声明 isStringisNumberPoint

文件间的依赖

当我们将命名空间拆分到不同的文件后,需要注意文件的加载顺序,因为文件之间可能存在依赖关系。例如,有两个拆分后的文件 a.tsb.ts

a.ts 文件的内容如下:

namespace App {
    export function isString(value: any) {
        return typeof value === 'string';
    }
}

b.ts 文件的内容如下:

namespace App {
    const a = isString('foo');
}

这两个文件中,b.ts 依赖于 a.ts。因为 b.ts 中调用了 a.ts 中定义的方法。我们需要保证 a.ts 先于 b.ts 被加载,否则在执行 b.ts 中的代码时将产生 isString 未定义的错误。

定义文件间的依赖关系有多种方式,本节将介绍以下两种:

  • 使用 tsconfig.json 文件。

  • 使用三斜线指令。

tsconfig.json

通过 tsconfig.json 配置文件能够定义文件间的加载顺序。例如,通过如下的配置文件能够定义 a.ts 先于 b.ts 被加载,这里我们主要配置了 outFilefiles 两个选项。示例如下:

{
    "compilerOptions": {
        "strict": true,
        "target": "ESNext",
        "outFile": "main.js"
    },
    "files": ["a.ts", "b.ts"]
}

首先,outFile 选项指定了编译后输出的文件名。在指定了该选项后,编译后的 a.tsb.ts 文件将被合并成一个 main.js 文件。其次,files 选项指定了工程中包含的所有源文件。files 文件列表是有序列表,我们正是通过它来保证 a.ts 先于 b.ts 被加载。最终编译后输出的 main.js 内容如下:

"use strict";
// a.ts
var App;
(function (App) {
    function isString(value) {
        return typeof value === 'string';
    }
    App.isString = isString;
})(App || (App = {}));
// b.ts
var App;
(function (App) {
    const a = App.isString('foo');
})(App || (App = {}));

由该输出文件能够看到 a.ts 位于 b.ts 之前,因此不会产生错误。

关于 tsconfig.json 的详细介绍请参考8.3节。

三斜线指令

三斜线指令是 TypeScript 早期版本中就支持的一个特性,我们可以通过它来定义文件间的依赖。三斜线指令的形式如下所示:

/// <reference path="a.ts" />

此例中的三斜线指令声明了对 a.ts 文件的依赖。

我们可以在 b.ts 中使用三斜线指令来声明对 a.ts 文件的依赖。

a.ts 文件的内容如下:

namespace App {
    export function isString(value: any) {
        return typeof value === 'string';
    }
}

b.ts 文件的内容如下:

/// <reference path="a.ts" />

namespace App {
    const a = isString('foo');
}

在使用了三斜线指令后,编译器能够识别出 b.ts 依赖于 a.ts。在编译 b.ts 之前,编译器会确保先编译 a.ts。就算在 tsconfig.json 配置文件的 files 选项中将 b.ts 放在了 a.ts 之前,编译器也能够识别出正确的依赖顺序。示例如下:

{
    "compilerOptions": {
        "strict": true,
        "target": "ESNext",
        "outFile": "main.js"
    },
    "files": ["b.ts", "a.ts"]
}

我们甚至都不需要在 files 选项中包含 a.ts 文件,只需要包含 b.ts 即可。因为在编译 b.ts 时,编译器将保证依赖的文件会一同被编译。示例如下:

{
    "compilerOptions": {
        "strict": true,
        "target": "ESNext",
        "outFile": "main.js"
    },
    "files": ["b.ts"]
}

使用以上两个例子中的 tsconfig.json 配置文件都能够得到正确且相同的输出文件 main.js。示例如下:

"use strict";
// a.ts
var App;
(function (App) {
    function isString(value) {
        return typeof value === 'string';
    }
    App.isString = isString;
})(App || (App = {}));
// b.ts
/// <reference path="a.ts" />
var App;
(function (App) {
    const a = App.isString('foo');
})(App || (App = {}));

小结

命名空间是一种历史悠久的实现代码封装和隔离的方式。在 JavaScript 语言还没有支持模块时,命名空间极为流行。在 TypeScript 语言的源码中也大量地使用了命名空间。随着近些年 JavaScript 模块系统的演进以及原生模块功能的推出,命名空间的使用场景将变得越来越有限并有可能退出历史舞台。在新的工程中或面向未来的代码中,推荐优先选择模块来代替命名空间。接下来,我们就将介绍 TypeScript 中的模块。