# 模块
从 ECMAScript 2015 开始,JavaScript 有了模块的概念。TypeScript 共享这个概念。
模块在自己的作用域内执行,而不是在全局作用域内执行;这意味着在模块中声明的变量、函数、类等在模块外部是不可见的,除非它们使用 export 表格
.x 之一显式导出。相反,要使用从不同模块导出的变量、函数、类、接口等,必须使用 import 表格
。
模块是声明性的;模块之间的关系是根据文件级别的导入和导出来指定的。
模块使用模块加载器相互导入。在运行时,模块加载器负责在执行模块之前定位和执行模块的所有依赖项。JavaScript 中使用的众所周知的模块加载器是 Node.js 的 CommonJS
模块加载器和 RequireJS
加载器,用于 Web 应用程序中的 AMD
模块。
在 TypeScript 中,就像在 ECMAScript 2015 中一样,任何包含顶级 import
或 export
的文件都被视为一个模块。相反,没有任何顶级 import
或 export
声明的文件被视为其内容在全局作用域内可用的脚本(因此也可用于模块)。
# 导出
# 导出声明
任何声明(例如变量、函数、类、类型别名或接口)都可以通过添加 export
关键字来导出。
# StringValidator.ts
export interface StringValidator {
isAcceptable(s: string): boolean;
}
# 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);
}
}
# 导出语句
当需要为消费者重命名导出时,导出语句很方便,所以上面的例子可以写成:
class ZipCodeValidator implements StringValidator {
isAcceptable(s: string) {
return s.length === 5 && numberRegexp.test(s);
}
}
export { ZipCodeValidator };
export { ZipCodeValidator as mainValidator };
# 重新导出
模块通常会扩展其他模块,并部分公开它们的一些特性。重新导出不会在本地导入它,也不会引入局部变量。
# ParseIntBasedZipCodeValidator.ts
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
export * from "./StringValidator"; // exports 'StringValidator' interface
export * from "./ZipCodeValidator"; // exports 'ZipCodeValidator' class and 'numberRegexp' constant value
export * from "./ParseIntBasedZipCodeValidator"; // exports the 'ParseIntBasedZipCodeValidator' class
// and re-exports 'RegExpBasedZipCodeValidator' as alias
// of the 'ZipCodeValidator' class from 'ZipCodeValidator.ts'
// module.
# 导入
导入与从模块导出一样简单。通过使用以下 import
表格之一来导入出口声明:
# 从模块导入单个导出
import { ZipCodeValidator } from "./ZipCodeValidator";
let myValidator = new ZipCodeValidator();
进口也可以重命名
import { ZipCodeValidator as ZCV } from "./ZipCodeValidator";
let myValidator = new ZCV();
# 将整个模块导入单个变量,并使用它来访问模块导出
import * as validator from "./ZipCodeValidator";
let myValidator = new validator.ZipCodeValidator();
# 导入仅用于副作用的模块
虽然不推荐实践,但某些模块设置了一些可供其他模块使用的全局状态。这些模块可能没有任何导出,或者消费者对它们的任何导出都不感兴趣。要导入这些模块,请使用:
import "./my-module.js";
# 导入类型
在 TypeScript 3.8 之前,您可以使用 import
导入类型。使用 TypeScript 3.8,您可以使用 import
语句或使用 import type
导入类型。
// Re-using the same import
import { APIResponseType } from "./api";
// Explicitly use import type
import type { APIResponseType } from "./api";
// Explicitly pull out a value (getResponse) and a type (APIResponseType)
import { getResponse, type APIResponseType} from "./api";
任何显式标记的 type
导入都保证会从您的 JavaScript 中删除,并且像 Babel 这样的工具可以通过 isolatedModules
编译器标志对您的代码做出更好的假设。您可以在 3.8 发行说明
中阅读更多内容。
# 默认导出
每个模块都可以选择导出一个 default
导出。默认导出用关键字 default
标记;每个模块只能有一个 default
导出。default
导出是使用不同的导入形式导入的。
default
出口真的很方便。例如,像 jQuery 这样的库可能默认导出 jQuery
或 $
,我们可能也会以 $
或 jQuery
的名称导入。
# JQuery.d.ts
declare let $: JQuery;
export default $;
# App.ts
import $ from "jquery";
$("button.continue").html("Next Step...");
类和函数声明可以直接编写为默认导出。默认导出类和函数声明名称是可选的。
# ZipCodeValidator.ts
export default class ZipCodeValidator {
static numberRegexp = /^[0-9]+$/;
isAcceptable(s: string) {
return s.length === 5 && ZipCodeValidator.numberRegexp.test(s);
}
}
# Test.ts
import validator from "./ZipCodeValidator";
let myValidator = new validator();
or
# StaticZipCodeValidator.ts
const numberRegexp = /^[0-9]+$/;
export default function (s: string) {
return s.length === 5 && numberRegexp.test(s);
}
# Test.ts
import validate from "./StaticZipCodeValidator";
let strings = ["Hello", "98052", "101"];
// Use function validate
strings.forEach((s) => {
console.log(`"${s}" ${validate(s) ? "matches" : "does not match"}`);
});
default
导出也可以只是值:
# OneTwoThree.ts
export default "123";
# Log.ts
import num from "./OneTwoThree";
console.log(num); // "123"
# 全部导出为 x
使用 TypeScript 3.8,您可以使用 export * as ns
作为重新导出另一个具有名称的模块的简写:
export * as utilities from "./utilities";
这会从模块中获取所有依赖项并使其成为导出字段,您可以像这样导入它:
import { utilities } from "./index";
# export = 和 import = require()
CommonJS 和 AMD 通常都有一个 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;
# Test.ts
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 (CommonJ)、require.js (AMD)、UMD、SystemJS 或 ECMAScript 2015 原生模块
(ES6) 模块加载系统生成适当的代码。有关生成代码中 define
、require
和 register
调用的更多信息,请参阅每个模块加载器的文档。
这个简单的例子展示了在导入和导出过程中使用的名称是如何被翻译成模块加载代码的。
# SimpleModule.ts
import m = require("mod");
export let t = m.something + 1;
# AMD / RequireJS SimpleModule.js
define(["require", "exports", "./mod"], function (require, exports, mod_1) {
exports.t = mod_1.something + 1;
});
# CommonJS / Node SimpleModule.js
var mod_1 = require("./mod");
exports.t = mod_1.something + 1;
# 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;
});
# 系统 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));
},
};
});
# 原生 ECMAScript 2015 模块 SimpleModule.js
import { something } from "./mod";
export var t = something + 1;
# 简单示例
下面,我们将前面示例中使用的 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;
}
# LettersOnlyValidator.ts
import { StringValidator } from "./Validation";
const lettersRegexp = /^[A-Za-z]+$/;
export class LettersOnlyValidator implements StringValidator {
isAcceptable(s: string) {
return lettersRegexp.test(s);
}
}
# 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);
}
}
# Test.ts
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("...")
语句使我们能够访问模块公开的类型。模块加载器被动态调用(通过 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("...")) {
/* ... */
}
}
# 示例: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("...")) {
/* ... */
}
});
}
# 示例: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("...")) {
/* ... */
}
});
}
# 使用其他 JavaScript 库
为了描述不是用 TypeScript 编写的库的形状,我们需要声明库公开的 API。
我们将未定义实现的声明称为 "ambient"。通常,这些在 .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;
}
现在我们可以 /// <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");
# 速记环境模块
如果您不想在使用新模块之前花时间写出声明,您可以使用速记声明来快速开始。
# declarations.d.ts
declare module "hot-new-module";
所有来自速记模块的导入都将具有 any
类型。
import x, { y } from "hot-new-module";
x(y);
# 通配符模块声明
某些模块加载器(例如 SystemJS
和 AMD
)允许导入非 JavaScript 内容。这些通常使用前缀或后缀来指示特殊的加载语义。通配符模块声明可用于涵盖这些情况。
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!*"
的内容。
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
export function isPrime(x: number): boolean;
export as namespace mathLib;
然后可以将该库用作模块中的导入:
import { isPrime } from "math-lib";
isPrime(2);
mathLib.isPrime(2); // ERROR: can't use the global definition from inside a module
它也可以用作全局变量,但只能在脚本内部使用。(脚本是没有导入或导出的文件。)
mathLib.isPrime(2);
# 结构化模块指南
# 尽可能接近顶层导出
在使用您导出的东西时,您的模块的使用者应该尽可能少地摩擦。添加过多的嵌套层次往往很麻烦,因此请仔细考虑您希望如何构建事物。
从模块中导出命名空间是添加太多嵌套层的示例。虽然命名空间有时有其用途,但它们在使用模块时增加了额外的间接级别。这很快就会成为用户的痛点,而且通常是不必要的。
导出类的静态方法也有类似的问题——类本身增加了一层嵌套。除非它以明显有用的方式增加表现力或意图,否则请考虑简单地导出辅助函数。
# 如果您只导出单个 class 或 function,请使用 export default
正如 "exporting near the top-level" 减少了模块使用者的摩擦一样,引入默认导出也是如此。如果一个模块的主要目的是容纳一个特定的导出,那么您应该考虑将其导出为默认导出。这使得导入和实际使用导入变得更加容易。例如:
# MyClass.ts
export default class SomeType {
constructor() { ... }
}
# MyFunc.ts
export default function getThing() {
return "thing";
}
# Consumer.ts
import t from "./MyClass";
import f from "./MyFunc";
let x = new t();
console.log(f());
这对消费者来说是最理想的。他们可以随意命名您的类型(在本例中为 t
),并且不必做任何过多的点来查找您的对象。
# 如果要导出多个对象,请将它们全部放在顶层
# MyThings.ts
export class SomeType {
/* ... */
}
export function someFunc() {
/* ... */
}
导入时相反:
# 显式列出导入的名称
# Consumer.ts
import { SomeType, someFunc } from "./MyThings";
let x = new SomeType();
let y = someFunc();
# 如果要导入大量内容,请使用命名空间导入模式
# MyLargeModule.ts
export class Dog { ... }
export class Cat { ... }
export class Tree { ... }
export class Flower { ... }
# Consumer.ts
import * as myLargeModule from "./MyLargeModule.ts";
let x = new myLargeModule.Dog();
# 重新导出以延长
通常您需要在模块上扩展功能。一种常见的 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()}'`);
}
这是使用暴露的 test
函数对计算器进行的简单测试。
# TestCalculator.ts
import { Calculator, test } from "./Calculator";
let c = new Calculator();
test(c, "1+2*33/11="); // prints 9
现在扩展它以添加对非 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)
);
}
}
}
// 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
import { Calculator, test } from "./ProgrammerCalculator";
let c = new Calculator(2);
test(c, "001+010="); // prints 3
# 不要在模块中使用命名空间
当第一次迁移到基于模块的组织时,一个常见的趋势是将导出包装在额外的命名空间层中。模块有自己的作用域,只有导出的声明从模块外部可见。考虑到这一点,命名空间在使用模块时提供的价值很少(如果有的话)。
在组织方面,命名空间可以方便地将全局作用域内的逻辑相关对象和类型组合在一起。例如,在 C# 中,您将在 System.Collections 中找到所有集合类型。通过将我们的类型组织到分层命名空间中,我们为这些类型的用户提供了良好的 "discovery" 体验。另一方面,模块必然已经存在于文件系统中。我们必须通过路径和文件名来解决它们,所以有一个逻辑组织方案供我们使用。我们可以有一个 /collections/generic/ 文件夹,其中包含一个列表模块。
命名空间对于避免全局作用域内的命名冲突很重要。例如,您可能有 My.Application.Customer.AddForm
和 My.Application.Order.AddForm
—— 两个具有相同名称但名称空间不同的类型。但是,这不是模块的问题。在一个模块中,没有合理的理由让两个对象具有相同的名称。从消费方面来看,任何给定模块的消费者都可以选择他们将用来指代模块的名称,因此不可能发生意外的命名冲突。
有关模块和命名空间的更多讨论,请参阅
命名空间和模块
。
# Red Flags
以下所有都是模块结构的危险信号。如果其中任何一个适用于您的文件,请仔细检查您是否没有尝试命名您的外部模块:
- 唯一顶级声明为
export namespace Foo { ... }
的文件(删除Foo
并将所有 'up' 的内容移至一级) - 在顶层具有相同
export namespace Foo {
的多个文件(不要认为这些文件会合并为一个Foo
!)