模块

状态: 初稿

对命名的说明:
有必要说明, 在 TypeScript 1.5 中, 一些术语发生了变化.
“内部模块” 现在叫做 “名字空间”.
“外部模块” 简称 “模块”, 这是出于同 ECMAScript 2015 的命名保持一致的考虑, (module X { 等同于现在提出的 namespace X {).

介绍

JavaScript 自 ECMAScript 2015 提出了模块的概念. TypeScript 将共享此概念.

一个模块不再在全局空间执行, 它们有了自己独有的模块空间; 也就是说, 模块外部看不到在模块内部定义的变量, 函数, 类等实体, 除非显式使用一种export 形式导出.
相反地, 要使用不同模块导出的变量, 函数, 类, 接口等, 也需要采用一种import 形式显式声明.

模块机制是声明式的, 模块间的关系由文件级的导入导出声明确定.

一个模块借助模块加载器导入另一个模块.
在运行期间, 模块加载器的职责是在一个模块执行之前定位和执行它所有依赖.
JavaScript 众所周知的模块加载器有负责加载 CommonJS 模块的 Node.js 加载器, 为 Web 应用加载 AMD 模块的 RequireJS 加载器.

在 TypeScript 中, 和 ECMAScript 2015 相同, 包含顶层 importexport 的任何文件都被认为是模块.
相反, 不包含顶层 importexport 声明的文件会按脚本文件对待, 它的内容全局可用 (也包括模块).

导出

导出一个定义

任何定义 (例如变量, 函数, 类, 类型别名, 接口) 都可以通过附加 export 关键字导出.

StringValidator.ts
1
2
3
export interface StringValidator {
isAcceptable(s: string): boolean;
}
ZipCodeValidator.ts
1
2
3
4
5
6
7
8
9
import { StringValidator } from "./StringValidator";

export const numberRegexp = /^[0-9]+$/;

export class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}

导出语句

用导出语句为用户重命名导出符号, 上例可以写成:

1
2
3
4
5
6
7
class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export { ZipCodeValidator };
export { ZipCodeValidator as mainValidator };

重导出

一般, 一个模块会扩充其他模块, 然后有选择性地导出它们的一些功能.
重导出不先将模块导入本地, 或引入本地变量.

ParseIntBasedZipCodeValidator.ts
1
2
3
4
5
6
7
8
export class ParseIntBasedZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && parseInt(s).toString() === s;
}
}

// Export original validator but rename it
export {ZipCodeValidator as RegExpBasedZipCodeValidator} from "./ZipCodeValidator";

如果你愿意的话, 可以用 export * from "module" 语法打包导出一个或多个模块的功能.

AllValidators.ts
1
2
3
4
5
6
export * from "./StringValidator"; // exports 'StringValidator' interface
export * from "./ZipCodeValidator"; // exports 'ZipCodeValidator' and const 'numberRegexp' class
export * from "./ParseIntBasedZipCodeValidator"; // exports the 'ParseIntBasedZipCodeValidator' class
// and re-exports 'RegExpBasedZipCodeValidator' as alias
// of the 'ZipCodeValidator' class from 'ZipCodeValidator.ts'
// module.

导入

导入差不多与从模块导出一样简单.
导入一个导出符号以以下任一 import 形式完成:

从模块导入单一符号

1
2
3
import { ZipCodeValidator } from "./ZipCodeValidator";

let myValidator = new ZipCodeValidator();

导入符号也允许被重命名

1
2
import { ZipCodeValidator as ZCV } from "./ZipCodeValidator";
let myValidator = new ZCV();

导入整个模块到一个变量, 借助该变量访问模块的导出

1
2
import * as validator from "./ZipCodeValidator";
let myValidator = new validator.ZipCodeValidator();

为副作用而导入模块

有些模块设置全局状态以影响其他模块.
这样的模块通常没有任何导出, 或用户对它的导出不感兴趣.
尽管不推荐, 如果你想为副作用导入模块, 使用:

1
import "./my-module.js";

默认导出

每个模块都可选择导出一个默认导出.
默认导出用关键字 default 标注; 每个模块只能有一个默认导出.
默认导出需要特殊的导入形式导入.

默认导出很是方便.
举个例子, jQuery 等库可能有一个默认导出 jQuery$, 我们也很可能用名字 $ 或 jQuery 导入.

JQuery.d.ts
1
2
declare let $: JQuery;
export default $;
App.ts
1
2
3
import $ from "jquery";

$("button.continue").html( "Next Step..." );

类或函数定义可直接标注为默认导出.
默认导出的类和函数的名字是可选的.

ZipCodeValidator.ts
1
2
3
4
5
6
export default class ZipCodeValidator {
static numberRegexp = /^[0-9]+$/;
isAcceptable(s: string) {
return s.length === 5 && ZipCodeValidator.numberRegexp.test(s);
}
}
Test.ts
1
2
3
import validator from "./ZipCodeValidator";

let myValidator = new validator();

StaticZipCodeValidator.ts
1
2
3
4
5
const numberRegexp = /^[0-9]+$/;

export default function (s: string) {
return s.length === 5 && numberRegexp.test(s);
}
Test.ts
1
2
3
4
5
6
7
8
import validate from "./StaticZipCodeValidator";

let strings = ["Hello", "98052", "101"];

// Use function validate
strings.forEach(s => {
console.log(`"${s}" ${validate(s) ? "matches" : "does not match"}`);
});

默认导出还可以仅是一个值:

OneTwoThree.ts
1
export default "123";
Log.ts
1
2
3
import num from "./OneTwoThree";

console.log(num); // "123"

export =import = require()

CommonJS 和 AMD 都有囊括一个模块所有导出的 exports 对象的概念.

它们也允许用一个自定义对象替代 exports 对象.
默认导出意图作为该功能的替代; 不过两者互不兼容.
TypeScript 提供了 export = 语法来模拟 CommonJS 和 AMD 工作流.

export = 语法指定一个从模块导出的对象.
它可以是类, 接口, 名字空间, 或枚举.

export = 导出一个模块后, 必须借助 TypeScript 特有的 import module = require("module") 导入它.

ZipCodeValidator.ts
1
2
3
4
5
6
7
let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export = ZipCodeValidator;
Test.ts
1
2
3
4
5
6
7
8
9
10
11
12
import zip = require("./ZipCodeValidator");

// Some samples to try
let strings = ["Hello", "98052", "101"];

// Validators to use
let validator = new zip();

// Show whether each string passed each validator
strings.forEach(s => {
console.log(`"${ s }" - ${ validator.isAcceptable(s) ? "matches" : "does not match" }`);
});

代码生成 — 模块

取决于在编译期指定的模块系统, 编译器会为 Node.js (CommonJS), require.js (AMD), UMD, SystemJS, 或 ECMAScript 2015 本地模块 (ES6) 模块加载系统生成合适的代码.
如要了解生成代码中 define, require, 和 register 调用的作用, 单独查询每个模块加载器的文档.

这个例子展示导入导出中使用的名字怎样转换成模块加载代码.

SimpleModule.ts
1
2
import m = require("mod");
export let t = m.something + 1;
AMD / RequireJS SimpleModule.js
1
2
3
define(["require", "exports", "./mod"], function (require, exports, mod_1) {
exports.t = mod_1.something + 1;
});
CommonJS / Node SimpleModule.js
1
2
var mod_1 = require("./mod");
exports.t = mod_1.something + 1;
UMD SimpleModule.js
1
2
3
4
5
6
7
8
9
10
11
(function (factory) {
if (typeof module === "object" && typeof module.exports === "object") {
var v = factory(require, exports); if (v !== undefined) module.exports = v;
}
else if (typeof define === "function" && define.amd) {
define(["require", "exports", "./mod"], factory);
}
})(function (require, exports) {
var mod_1 = require("./mod");
exports.t = mod_1.something + 1;
});
System SimpleModule.js
1
2
3
4
5
6
7
8
9
10
11
12
13
System.register(["./mod"], function(exports_1) {
var mod_1;
var t;
return {
setters:[
function (mod_1_1) {
mod_1 = mod_1_1;
}],
execute: function() {
exports_1("t", t = mod_1.something + 1);
}
}
});
ECMAScript 2015 本地模块 SimpleModule.js
1
2
import { something } from "./mod";
export var t = something + 1;

一个实例

下面, 我们通过只导出每个模块的一个命名符号来加强先前 Validator 的实现.

编译时, 在命令行参数指定所需模块系统. 对于 Node.js, 这参数是 --module commonjs;
对 require.js, 则是 --module amd. 请看示例:

1
tsc --module commonjs Test.ts

经过编译, 每个模块生成各自的 .js 文件.
类似于 reference 标签, 编译器会遵循 import 语句编译每个依赖文件.

Validation.ts
1
2
3
export interface StringValidator {
isAcceptable(s: string): boolean;
}
LettersOnlyValidator.ts
1
2
3
4
5
6
7
8
9
import { StringValidator } from "./Validation";

const lettersRegexp = /^[A-Za-z]+$/;

export class LettersOnlyValidator implements StringValidator {
isAcceptable(s: string) {
return lettersRegexp.test(s);
}
}
ZipCodeValidator.ts
1
2
3
4
5
6
7
8
9
import { StringValidator } from "./Validation";

const numberRegexp = /^[0-9]+$/;

export class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
Test.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { StringValidator } from "./Validation";
import { ZipCodeValidator } from "./ZipCodeValidator";
import { LettersOnlyValidator } from "./LettersOnlyValidator";

// Some samples to try
let strings = ["Hello", "98052", "101"];

// Validators to use
let validators: { [s: string]: StringValidator; } = {};
validators["ZIP code"] = new ZipCodeValidator();
validators["Letters only"] = new LettersOnlyValidator();

// Show whether each string passed each validator
strings.forEach(s => {
for (let name in validators) {
console.log(`"${ s }" - ${ validators[name].isAcceptable(s) ? "matches" : "does not match" } ${ name }`);
}
});

模块按需加载及其他高级加载场景

在某些情况下, 你可能希望满足一定条件才加载所需模块.
在 TypeScript 中, 你可以使用以下模式实现模块按需加载和其他高级加载场景, 在不损失类型安全的前提下直接调用模块加载器.

编译器检测输出的 JavaScript 是否使用了每个模块.
如果模块标识符总是出现在类型注解中, 从未在表达式中使用, 编译器便不会为这个模块生成相应的 require 调用.
对未使用引用的消除能优化性能, 也实现了对这些模块的按需加载.

这模式的核心想法是 import id = require("...") 语句让我们能访问模块导出的类型.
在以下 if 块中, 对模块加载器的调用是动态的 (通过 require).
这里运用了引用消除优化, 所以模块只在必要时加载.
要使这模式奏效, 用 import 定义的标识符只能用在类型信息位置 (该位置不会输出至 JavaScript).

为了维护类型安全, 我们可以利用 typeof 关键字.
在类型信息位置使用 typeof 关键字, 将产生一个值的类型, 此例是输出模块的类型.

Node.js 动态模块加载
1
2
3
4
5
6
7
8
9
declare function require(moduleName: string): any;

import { ZipCodeValidator as Zip } from "./ZipCodeValidator";

if (needZipValidation) {
let ZipCodeValidator: typeof Zip = require("./ZipCodeValidator");
let validator = new ZipCodeValidator();
if (validator.isAcceptable("...")) { /* ... */ }
}
实例: require.js 动态模块加载
1
2
3
4
5
6
7
8
9
10
declare function require(moduleNames: string[], onLoad: (...args: any[]) => void): void;

import * as Zip from "./ZipCodeValidator";

if (needZipValidation) {
require(["./ZipCodeValidator"], (ZipCodeValidator: typeof Zip) => {
let validator = new ZipCodeValidator.ZipCodeValidator();
if (validator.isAcceptable("...")) { /* ... */ }
});
}
实例: System.js 动态模块加载
1
2
3
4
5
6
7
8
9
10
declare const System: any;

import { ZipCodeValidator as Zip } from "./ZipCodeValidator";

if (needZipValidation) {
System.import("./ZipCodeValidator").then((ZipCodeValidator: typeof Zip) => {
var x = new ZipCodeValidator();
if (x.isAcceptable("...")) { /* ... */ }
});
}

与其他 JavaScript 库协同工作

要描述非 TypeScript 库的形体, 我们需要声明该库暴露的 API.

我们称没有定义实现的声明为 “外部的”.
按照惯例, 把这些声明放置在 .d.ts 文件中.
如果你熟悉 C/C++, 它们相当于 .h 文件.
一起来看些例子.

外部模块

在 Node.js 中, 多数任务都要加载一个或多个模块才能完成.
我们可以声明顶层导出, 为每个模块建立专有的 .d.ts 文件, 但把所有导出声明放到一个大 .d.ts 文件更易使用.
我们采用与外部名字空间差不多的结构达成目的, 组合 module 关键字和以引号引起来的模块名, 提供对导入有用的信息.

node.d.ts (摘要)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
declare module "url" {
export interface Url {
protocol?: string;
hostname?: string;
pathname?: string;
}

export function parse(urlStr: string, parseQueryString?, slashesDenoteHost?): Url;
}

declare module "path" {
export function normalize(p: string): string;
export function join(...paths: any[]): string;
export var sep: string;
}

此时, 先以 /// <reference> node.d.ts 添加引用, 然后用 import url = require("url")import * as URL from "url" 加载模块.

1
2
3
/// <reference path="node.d.ts"/>
import * as URL from "url";
let myUrl = URL.parse("http://www.typescriptlang.org");

原始外部模块

如果你不愿在使用一个新模块之前, 花时间编写模块声明, 原始外部模块让你快速开始.

declarations.d.ts
1
declare module "hot-new-module";

所有来自原始外部模块的导入的类型都是 any.

1
2
import x, {y} from "hot-new-module";
x(y);

带通配符的模块声明

SystemJSAMD 等模块加载器允许你加载非 JavaScript 内容.
它们一般依靠前缀或后缀指示特殊加载语义.
带通配符的模块声明可以发挥作用.

1
2
3
4
5
6
7
8
9
declare module "*!text" {
const content: string;
export default content;
}
// Some do it the other way around.
declare module "json!*" {
const value: any;
export default value;
}

现在你可以导入匹配 "*!text""json!*" 的定义了.

1
2
3
import fileContent from "./xyz.txt!text";
import data from "json!http://example.com/data.json";
console.log(data, fileContent);

UMD 模块

很多库设计为能为多个模块加载器所加载, 也有的不需要模块加载 (全局变量).
它们统称为 UMD 模块.
这些库可以通过导入语句或全局变量访问.
请看下例:

math-lib.d.ts
1
2
export function isPrime(x: number): boolean;
export as namespace mathLib;

随后, 在模块中, 用 import 导入这个库:

1
2
3
import { isPrime } from "math-lib";
isPrime(2);
mathLib.isPrime(2); // ERROR: can't use the global definition from inside a module

它也可以作为全局变量访问, 但只有脚本文件允许.
(脚本文件是不包含任何 import, export 语句的源文件)

1
mathLib.isPrime(2);

模块组织指南

导出尽可能靠近顶层

你模块的用户在使用你模块导出时阻力应越小越好.
向模块添加过多嵌套层次等于给用户制造麻烦, 在组织模块结构时必须深思熟虑.

从模块导出名字空间便是添加过多嵌套层次的反面教材.
在使用模块时, 名字空间为它添加了额外的一层间接访问, 有时是有用的.
但它很容易成为用户痛点, 同时很难说是必要的.

导出类中的静态方法也存在同样的问题 - 类本身增加了额外的一层嵌套.
除非为了可读性考虑, 或有清晰的意图, 导出一个简单的帮助函数(helper function)更好.

导出单个函数, 别忘记默认导出

出于对”‘导出靠近顶层’能减轻模块用户使用阻力”这一原则的认同, 我们才有默认导出.
如果一个模块仅包含一特定功能, 你应该考虑将它默认导出.
这使得以后不论是导入或实际使用都更简单.
举个例子:

MyClass.ts

1
2
3
export default class SomeType {
constructor() { ... }
}

MyFunc.ts

1
export default function getThing() { return "thing"; }

Consumer.ts

1
2
3
4
import t from "./MyClass";
import f from "./MyFunc";
let x = new t();
console.log(f());

这是对用户最理想的方法. 他们可以按自己喜好给你的类型起名 (本例是 t), 同时不需过多点运算去寻找你的对象.

导出多个对象, 把它们全置于顶层

MyThings.ts

1
2
export class SomeType { /* ... */ }
export function someFunc() { /* ... */ }

导入时:

显式列出导入名

Consumer.ts

1
2
3
import { SomeType, someFunc } from "./MyThings";
let x = new SomeType();
let y = someFunc();

在导入大量目标时运用名字空间导入模式

MyLargeModule.ts

1
2
3
4
export class Dog { ... }
export class Cat { ... }
export class Tree { ... }
export class Flower { ... }

Consumer.ts

1
2
import * as myLargeModule from "./MyLargeModule.ts";
let x = new myLargeModule.Dog();

重导出以扩充

有时你需要扩充一个模块的功能.
扩展增强原始对象是种 JS 设计模式, 与 JQuery 扩展的工作方式相仿.
前文已提到, 模块不像全局名字空间会跨文件合并.
受推荐的解决方案是修改原始对象, 相反, 导出提供新功能的另一实体.

考虑定义在 Calculator.ts 模块中的计算器实现.
该模块同时导出一个帮助函数, 它接受一个输入字符串列表, 在最后打印结果, 以测试计算器功能.

Calculator.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
export class Calculator {
private current = 0;
private memory = 0;
private operator: string;

protected processDigit(digit: string, currentValue: number) {
if (digit >= "0" && digit <= "9") {
return currentValue * 10 + (digit.charCodeAt(0) - "0".charCodeAt(0));
}
}

protected processOperator(operator: string) {
if (["+", "-", "*", "/"].indexOf(operator) >= 0) {
return operator;
}
}

protected evaluateOperator(operator: string, left: number, right: number): number {
switch (this.operator) {
case "+": return left + right;
case "-": return left - right;
case "*": return left * right;
case "/": return left / right;
}
}

private evaluate() {
if (this.operator) {
this.memory = this.evaluateOperator(this.operator, this.memory, this.current);
}
else {
this.memory = this.current;
}
this.current = 0;
}

public handleChar(char: string) {
if (char === "=") {
this.evaluate();
return;
}
else {
let value = this.processDigit(char, this.current);
if (value !== undefined) {
this.current = value;
return;
}
else {
let value = this.processOperator(char);
if (value !== undefined) {
this.evaluate();
this.operator = value;
return;
}
}
}
throw new Error(`Unsupported input: '${char}'`);
}

public getResult() {
return this.memory;
}
}

export function test(c: Calculator, input: string) {
for (let i = 0; i < input.length; i++) {
c.handleChar(input[i]);
}

console.log(`result of '${input}' is '${c.getResult()}'`);
}

下面给出一个调用 test 函数对计算器的测试.

TestCalculator.ts

1
2
3
4
import { Calculator, test } from "./Calculator";

let c = new Calculator();
test(c, "1+2*33/11="); // prints 9

现在, 扩充这个模块, 让它接受不以 10 为底的进制数作为输入, 创建新文件 ProgrammerCalculator.ts

ProgrammerCalculator.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Calculator } from "./Calculator";

class ProgrammerCalculator extends Calculator {
static digits = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "A", "B", "C", "D", "E", "F"];

constructor(public base: number) {
super();
const maxBase = ProgrammerCalculator.digits.length;
if (base <= 0 || base > maxBase) {
throw new Error(`base has to be within 0 to ${maxBase} inclusive.`);
}
}

protected processDigit(digit: string, currentValue: number) {
if (ProgrammerCalculator.digits.indexOf(digit) >= 0) {
return currentValue * this.base + ProgrammerCalculator.digits.indexOf(digit);
}
}
}

// Export the new extended calculator as Calculator
export { ProgrammerCalculator as Calculator };

// Also, export the helper function
export { test } from "./Calculator";

新模块 ProgrammerCalculator 导出与原始 Calculator 模块相同的 API , 但没有增强原始模块中任何对象.
针对 ProgrammerCalculator 类的测试如下:

TestProgrammerCalculator.ts

1
2
3
4
import { Calculator, test } from "./ProgrammerCalculator";

let c = new Calculator(2);
test(c, "001+010="); // prints 3

不要在模块中使用名字空间

首次转移到基于模块的组织结构的人, 都倾向把导出符号包裹在额外的名字空间层中.
模块有自己独立的空间, 只有导出的声明才对模块外部可见.
以此为前提, 与模块一并使用, 名字空间即便还有价值, 也是极其有限的.

模块以先, 名字空间能够在全局空间内给逻辑上相关的对象和类型分组.
比如说, 在 C# 中, 你会在 System.Collections 名字空间找到所有与集合相关的类型.
分层次把我们的类型组织在名字空间中, 用户在这些类型上可获得某种”探索”体验.
另一方面, 模块, 存在于文件系统.
我们通过文件路径和文件名找到他们, 这本来就是一种逻辑组织形式.
你可以创建 /collections/generic/ 文件夹, 存放所有列表相关模块.

在全局空间中, 名字空间是避免命名冲突的重要手段.
比如, 你可以定义 My.Application.Customer.AddFormMy.Application.Order.AddForm — 两个名字一样, 所属名字空间不一样的两个类型.
而对于模块, 没必要担心命名冲突.
在一个模块内部, 出现两个名字一样的对象本身就不合理.
站在用户一端, 任一给定模块的用户会起一个它们用来引用该模块的名称, 避免了意外发生命名冲突.

阅读名字空间和模块获得更多相关讨论.

禁忌

以下两点都是用模块组织的禁忌. 如果你的文件满足任意一条, 双重确认你没使用名字空间:

  • 它唯一顶层导出是 export namesapce Foo { ... } (移除 Foo, 所有东西’往上’移动一层)
  • 多个文件包含相同顶层 export namespace Foo (别期望它们会合并成一个 Foo!)
如果这篇文章对您有用,可以考虑打赏:)
Haiyang Li 微信 微信