TypeScript Office - 模块进阶
# 模块
从 ECMAScript 2015 开始,JavaScript 有一个模块的概念。TypeScript 也有这个概念。
模块在自己的范围内执行,而不是在全局范围内;这意味着在模块中声明的变量、函数、类等在模块外是不可见的,除非它们被明确地使用其中一种导出形式导出。相反,要使用从不同模块导出的变量、函 数、类、接口等,必须使用导入的形式将其导入。
模块是声明性的;模块之间的关系是在文件级别上以导入和导出的方式指定的。
模块使用模块加载器相互导入。在运行时,模块加载器负责在执行一个模块之前定位和执行该模块的所有依赖关系。在 JavaScript 中使用的著名的模块加载器是 Node.js 的 CommonJS 模块的加载器和 Web 应用程序中 AMD 模块的 RequireJS 加载器。
在 TypeScript 中,就像在 ECMAScript 2015 中一样,任何包含顶级 import 或 export 的文件都被认为是一个模块。相反,一个没有任何顶级 import 或 export 声明的文件被视为一个脚本,其内容可在全局范围内使用(因此也可用于模块)。
# 导出
# 导出声明
任何声明(如变量、函数、类、类型别名或接口)都可以通过添加 export 关键字而被导出。
StringValidator.ts
export interface StringValidator {
isAcceptable(s: string): boolean;
}
2
3
ZipCodeValidator.ts
import { StringValidator } from "./StringValidator";
export const numberRegexp = /^[0-9]+$/;
export class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
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 };
2
3
4
5
6
7
# 二次导出
通常情况下,模块会扩展其他模块,并部分地暴露出它们的一些特性。一个二次导出并不在本地导入,也不引入本地变量。
ParseIntBasedZipCodeValidator.ts
export class ParseIntBasedZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && parseInt(s).toString() === s;
}
}
// 导出原始验证器但重新命名
export { ZipCodeValidator as RegExpBasedZipCodeValidator } from
"./ZipCodeValidator";
2
3
4
5
6
7
8
9
另外,一个模块可以包裹一个或多个模块,并使用 export * from "module"
语法组合它们的所有导出。
AllValidators.ts
export * from "./StringValidator"; // 导出'StringValidator'接口
export * from "./ZipCodeValidator"; // 导出'ZipCodeValidator'类和'numberRegexp'常量值
export * from "./ParseIntBasedZipCodeValidator"; // 从'ZipCodeValidator.ts'模块导出'ParseIntBasedZipCodeValidator'类并重新导出'RegExpBasedZipCodeValidator'作为'ZipCodeValidator'类的别名。
2
3
4
5
# 导入
导入和从模块中导出一样简单。导入一个导出的声明是通过使用下面的一个导入表格完成的。
从一个模块中导入一个单一的导出。
import { ZipCodeValidator } from "./ZipCodeValidator";
let myValidator = new ZipCodeValidator();
2
导入也可以被重新命名:
import { ZipCodeValidator as ZCV } from "./ZipCodeValidator";
let myValidator = new ZCV();
2
将整个模块导入到一个变量中,并使用它来访问模块的出口。
import * as validator from "./ZipCodeValidator";
let myValidator = new validator.ZipCodeValidator();
2
导入一个只有副作用的模块。
虽然不是推荐的做法,但有些模块设置了一些全局状态,可以被其他模块使用。这些模块可能没有任何出口,或者消费者对它们的任何出口不感兴趣。要导入这些模块,请使用:
import "./my-module.js";
在 TypeScript 3.8 之前,你可以使用 import 导入一个类型。在 TypeScript 3.8 中,你可以使用 import 语句导入一个类型,或者使用 import type
。
// 重复使用相同的 import
import { APIResponseType } from "./api";
// 明确使用导入类型
import type { APIResponseType } from "./api";
2
3
4
import type
总是被保证从你的 JavaScript 中删除,而且像 Babel 这样的工具可以通过 isolatedModules 编译器标志对你的代码做出更好的假设。
# 默认输出
每个模块都可以选择输出一个 default 输出。默认输出用关键字 default 标记;每个模块只能有一个 default 输出。default 输出使用不同的导入形式导入。
default 导出真的很方便。例如,像 JQuery 这样的库可能有一个默认导出的 jQuery 或 $,我们可能也会以 $ 或 JQuery 的名字导入。
jQuery.d.ts
declare let $: JQuery;
export default $;
2
App.ts
import $ from "jquery";
$("button.continue").html("Next Step...");
2
类和函数声明可以直接作为默认导出而编写。默认导出的类和函数声明名称是可选的。
ZipCodeValidator.ts
export default class ZipCodeValidator {
static numberRegexp = /^[0-9]+$/;
isAcceptable(s: string) {
return s.length === 5 && ZipCodeValidator.numberRegexp.test(s);
}
}
2
3
4
5
6
Test.ts
import validator from "./ZipCodeValidator";
let myValidator = new validator();
2
或者:
StaticZipCodeValidator.ts
const numberRegexp = /^[0-9]+$/;
export default function (s: string) {
return s.length === 5 && numberRegexp.test(s);
}
2
3
4
Test.ts
import validate from "./StaticZipCodeValidator";
let strings = ["Hello", "98052", "101"];
// 使用函数验证
strings.forEach((s) => {
console.log(`"${s}" ${validate(s) ? "matches" : "does not match"}`);
});
2
3
4
5
6
default 出口也可以只是数值。
OneTwoThree.ts
export default "123";
Log.ts
import num from "./OneTwoThree";
console.log(num); // "123"
2
# as x 导出全部
在 TypeScript 3.8 中,你可以使用 export * as ns
作为一种速记方法来重新导出另一个有名字的模块。
export * as utilities from "./utilities";
这从一个模块中获取所有的依赖性,并使其成为一个导出的字段,你可以像这样导入它:
import { utilities } from "./index";
# export = 与 import = require()
CommonJS 和 AMD 通常都有一个 exports 对象的概念,它包含了一个模块的所有出口。
它们也支持用一个自定义的单一对象来替换 exports 对象。默认的 exports 是为了作为这种行为的替代;然而,两者是不兼容的。TypeScript 支持 export =
来模拟传统的 CommonJS 和 AMD 工作流程。
export =
语法指定了一个从模块导出的单一对象。这可以是一个类,接口,命名空间,函数,或枚举。
当使用 export =
导出一个模块时,必须使用 TypeScript 特定的 import module = require("module")
来导入模块。
ZipCodeValidator.ts
let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export = ZipCodeValidator;
2
3
4
5
6
7
Test.ts
import zip = require("./ZipCodeValidator");
// 一些可以尝试的样本
let strings = ["Hello", "98052", "101"];
// 要使用的验证器
let validator = new zip();
// 显示每个字符串是否通过每个验证器
strings.forEach((s) => {
console.log(
`"${s}" - ${validator.isAcceptable(s) ? "matches" : "does not match"}`
);
});
2
3
4
5
6
7
8
9
10
11
# 模块的代码生成
根据编译时指定的模块目标,编译器将为 Node.js(CommonJS)、require.js(AMD)、UMD、SystemJS 或 ECMAScript 2015 本地模块(ES6)模块加载系统生成相应的代码。关于生成的代码中的 define、require 和 register 调用的更多信息,请查阅每个模块加载器的文档。这个简单的例子显示了,导入和导出过程中使用的名称,是如何被翻译成模块加载代码的。
SimpleModule.ts
import m = require("mod");
export let t = m.something + 1;
2
AMD / RequireJS SimpleModule.js
define(["require", "exports", "./mod"], function (require, exports, mod_1) {
exports.t = mod_1.something + 1;
});
2
3
CommonJS / Node SimpleModule.js
var mod_1 = require("./mod");
exports.t = mod_1.something + 1;
2
UMD SimpleModule.js
(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;
});
2
3
4
5
6
7
8
9
10
11
System SimpleModule.js
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));
},
};
});
2
3
4
5
6
7
8
9
10
11
12
13
14
Native ECMAScript 2015 modules SimpleModule.js
import { something } from "./mod";
export var t = something + 1;
2
# 案例
下面,我们整合了之前例子中使用的 Validator 实现,只从每个模块导出一个命名的导出。
进行编译,我们必须在命令行中指定一个模块目标。对于 Node.js,使用 --module commonjs
;对于 require.js,使用 --module amd
。比如说:
tsc --module commonjs Test.ts
编译时,每个模块将成为一个单独的 .js
文件。与参考标签一样,编译器将遵循 import 语句来编译依赖的文件。
Validation.ts
export interface StringValidator {
isAcceptable(s: string): boolean;
}
2
3
LettersOnlyValidator.ts
import { StringValidator } from "./Validation";
const lettersRegexp = /^[A-Za-z]+$/;
export class LettersOnlyValidator implements StringValidator {
isAcceptable(s: string) {
return lettersRegexp.test(s);
}
}
2
3
4
5
6
7
ZipCodeValidator.ts
import { StringValidator } from "./Validation";
const numberRegexp = /^[0-9]+$/;
export class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
2
3
4
5
6
7
Test.ts
import { StringValidator } from "./Validation";
import { ZipCodeValidator } from "./ZipCodeValidator";
import { LettersOnlyValidator } from "./LettersOnlyValidator";
// 一些可以尝试的样本
let strings = ["Hello", "98052", "101"];
// 要使用的验证器
let validators: { [s: string]: StringValidator } = {};
validators["ZIP code"] = new ZipCodeValidator();
validators["Letters only"] = new LettersOnlyValidator();
// 显示每个字符串是否通过每个验证器
strings.forEach((s) => {
for (let name in validators) {
console.log(
`"${s}" - ${
validators[name].isAcceptable(s) ? "matches" : "does not match"
} ${name}`
);
}
});
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 可选模块加载和其他高级加载场景
在某些情况下,你可能只想在某些条件下加载一个模块。在 TypeScript 中,我们可以使用下面所示的模式来实现这个和其他高级的加载场景,直接调用模块加载器而不失去类型安全。
编译器会检测每个模块是否在编译好的 JavaScript 中被使用。如果一个模块的标识符只被用作类型注释的一部分,而从未被用作表达式,那么就不会为该模块编译 require 调用。这种对未使用的引用的消除是一种很好的性能优化,同时也允许对这些模块进行选择性加载。
该模式的核心思想是,import id = require("...")
语句使我们能够访问模块所暴露的类型。模块加载器(通过 require)被动态地调用,如下面的 if 块所示。这样就利用了引用隔离的优化,使模块只在需要时才被加载。为了使这种模式发挥作用,重要的是通过 import 定义的符号只在类型位置使用(也就是说,决不在会被编译到 JavaScript 的位置)。
为了维护类型安全,我们可以使用 typeof 关键字。typeof 关键字在类型位置上使用时,会产生一个值的类型,在这里是模块的类型。
- Node.js 中的动态模块加载
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("...")) {
/* ... */
}
}
2
3
4
5
6
7
8
9
- 例子:在 require.js 中动态加载模块
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("...")) {
/* ... */
}
});
}
2
3
4
5
6
7
8
9
10
11
12
13
- 例子:System.js 中的动态模块加载
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("...")) {
/* ... */
}
});
}
2
3
4
5
6
7
8
9
10
# 与其他 JavaScript 库一起工作
为了描述不是用 TypeScript 编写的库的形状,我们需要声明该库所暴露的 API。
我们把不定义实现的声明称为「环境」。通常情况下,这些都是在 .d.ts
文件中定义的。如果你熟悉 C/C++,你可以把它们看作是 .h
文件。让我们来看看几个例子。
# 环境模块
在 Node.js 中,大多数任务是通过加载一个或多个模块完成的。我们可以在自己的 .d.ts
文件中定义每个模块,并进行顶层导出声明,但把它们写成一个更大的 .d.ts
文件会更方便。要做到这一点,我们使用一个类似于环境命名空间的结构,但我们使用 module 关键字和引号的模块名称,这将在以后的导入中可用。比如说:
node.d.ts (简要摘录)
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;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
现在我们可以 /// <reference> node.d.ts
,然后使用 import url = require("url");
或 import * as URL from "url"
加载模块。
/// <reference path="node.d.ts"/>
import * as URL from "url";
let myUrl = URL.parse("https://www.typescriptlang.org");
2
3
# 速记的环境模块
如果你不想在使用一个新模块之前花时间写出声明,你可以使用速记声明来快速入门。
declarations.d.ts
declare module "hot-new-module";
所有来自速记模块的导入都将具有任意类型。
import x, { y } from "hot-new-module";
x(y);
2
# 通配符模块的声明
一些模块加载器,如 SystemJS 和 AMD 允许导入非 JavaScript 内容。这些模块通常使用一个前缀或后缀来表示特殊的加载语义。通配符模块声明可以用来涵盖这些情况。
declare module "*!text" {
const content: string;
export default content;
}
// 有些人则反其道而行之。
declare module "json!*" {
const value: any;
export default value;
}
2
3
4
5
6
7
8
9
现在你可以导入符合 "*!text "
或 "json!*"
的东西。
import fileContent from "./xyz.txt!text";
import data from "json!http://example.com/data.json";
console.log(data, fileContent);
2
3
# UMD 模块
有些库被设计成可以在许多模块加载器中使用,或者没有模块加载(全局变量)也可以。这些被称为 UMD 模块。这些库可以通过导入或全局变量访问。比如说:
math-lib.d.ts
export function isPrime(x: number): boolean;
export as namespace mathLib;
2
然后,该库可以作为模块内的导入使用:
import { isPrime } from "math-lib";
isPrime(2);
mathLib.isPrime(2); // 错误:不能从模块内部使用全局定义
2
3
它也可以作为一个全局变量使用,但只能在一个脚本中使用。(脚本是一个没有导入或导出的文件)。
mathLib.isPrime(2);
# 构建模块的指导意见
# 尽可能接近顶层导出(export)
你的模块的消费者在使用你道出的东西时,应该有尽可能少的困扰。增加过多的嵌套层次往往是很麻烦的,所以要仔细考虑你想如何组织代码。
从你的模块中导出一个命名空间,就是一个增加过多嵌套层次的例子。虽然命名空间有时有其用途,但在使用模块时,它们增加了额外的间接性。这很快就会成为用户的一个痛点,而且通常是不必要的。
输出类上的静态方法也有类似的问题,类本身增加了一层嵌套。除非它以一种明显有用的方式增加了表达能力或意图,否则考虑简单地导出一个辅助函数。
- 如果你只导出了一个 class 或 function 则使用
export default
正如「在顶层导出」可以减少模块消费者的困扰,引入一个默认导出也是如此。如果一个模块的主要目的是容纳一个特定的出口,那么你应该考虑把它作为一个默认出口。这使得导入和实际使用导入都更容易一些。比如说:
MyClass.ts
export default class SomeType {
constructor() { ... }
}
2
3
MyFunc.ts
export default function getThing() {
return "thing";
}
2
3
Consumer.ts
import t from "./MyClass";
import f from "./MyFunc";
let x = new t();
console.log(f());
2
3
4
这对消费者来说是最好的。他们可以随心所欲地命名你的类型(本例中为 t ),并且不必做任何过度的点缀来寻找你的对象。
- 如果你要导出多个对象,把它们都放在顶层
MyThings.ts
export class SomeType {
/* ... */
}
export function someFunc() {
/* ... */
}
2
3
4
5
6
反之,在导入时,也是如此。
- 明确列出进口名称
Consumer.ts
import { SomeType, someFunc } from "./MyThings";
let x = new SomeType();
let y = someFunc();
2
3
如果你要导入大量的东西,请使用命名空间导入模式:
MyLargeModule.ts
export class Dog { ... }
export class Cat { ... }
export class Tree { ... }
export class Flower { ... }
2
3
4
Consumer.ts
import * as myLargeModule from "./MyLargeModule.ts";
let x = new myLargeModule.Dog();
2
# 扩展的重新导出
通常情况下,你需要在一个模块上扩展功能。一个常见的 JS 模式是用扩展来增强原始对象,类似于 JQuery 扩展的工作方式。正如我们之前提到的,模块不会像全局命名空间对象那样进行合并。推荐的解决方案是不改变原始对象,而是导出一个提供新功能的新实体。
考虑一个简单的计算器实现,定义在模块 Calculator.ts 中。该模块还导出了一个辅助函数,通过传递一个输入字符串列表并在最后写入结果,来测试计算器的功能。
Calculator.ts
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()}'`);
}
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
72
下面是一个使用暴露测试功能的计算器的简单测试。
TestCalculator.ts
import { Calculator, test } from "./Calculator";
let c = new Calculator();
test(c, "1+2*33/11="); // 输出 9
2
3
现在,为了扩展这个功能,以增加对 10 以外的数字输入的支持,我们来创建 ProgrammerCalculator.ts
ProgrammerCalculator.ts
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)
);
}
}
}
// 将新的扩展计算器导出为 Calculator
export { ProgrammerCalculator as Calculator };
// 同时,导出辅助函数
export { test } from "./Calculator";
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
新模块 ProgrammerCalculator 输出的 API 形状与原来的 Calculator 模块相似,但并没有增强原来模块中的任何对象。下面是对我们的 ProgrammerCalculator 类的测试。
TestProgrammerCalculator.ts
import { Calculator, test } from "./ProgrammerCalculator";
let c = new Calculator(2);
test(c, "001+010="); // 输出 3
2
3
# 不要在模块中使用命名空间
当第一次转移到基于模块的代码设计时,一个常见的趋势是,将 export 包裹在一个额外的命名空间层中。模块有自己的范围,只有导出的声明在模块外可见。考虑到这一点,如果有命名空间的话,它在使用模块时提供的价值非常小。
在组织方面,命名空间对于在全局范围内,将逻辑上相关的对象和类型组合在一起很方便。例如,在 C# 中,你会在 System.Collections
中找到所有的集合类型。通过将我们的类型组织到分层的命名空间中,我们为这些类型的用户提供了良好的「发现」体验。另一方面,模块已经存在于文件系统中,是必然的。我们必须通过路径和文件名来解决它们,所以有一个逻辑的组织方案供我们使用。我们可以有一个 /collections/generic/
文件夹,里面有一个列表模块。
命名空间对于避免全局范围内的命名冲突很重要。例如,你可能有 My.Application.Customer.AddForm
和 My.Application.Order.AddForm
两个名字相同的类型,但名字空间不同。然而,对于模块来说,这不是一个问题。在一个模块中,没有合理的理由让两个对象具有相同的名字。从消费方面来看,任何给定模块的消费者都可以选择他们将用来引用模块的名称,所以意外的命名冲突是不可能的。
# 红线
以下所有情况都是模块结构化的红线。如果你的文件有这些情况,请仔细检查你是否试图,对你的外部模块进行命名空间定义。
- 一个文件的唯一顶层声明是
export namespace Foo { ... }
(移除 Foo,并将所有内容「上移」一个级别) - 多个文件在顶层有相同的
export namespace Foo { ... }
(不要以为这些文件会合并成一个 Foo)