模块
状态: 初稿
对命名的说明:
有必要说明, 在 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 相同, 包含顶层 import
或 export
的任何文件都被认为是模块.
相反, 不包含顶层 import
或 export
声明的文件会按脚本文件对待, 它的内容全局可用 (也包括模块).
导出
导出一个定义
任何定义 (例如变量, 函数, 类, 类型别名, 接口) 都可以通过附加 export
关键字导出.
StringValidator.ts
1 | export interface StringValidator { |
ZipCodeValidator.ts
1 | import { StringValidator } from "./StringValidator"; |
导出语句
用导出语句为用户重命名导出符号, 上例可以写成:
1 | class ZipCodeValidator implements StringValidator { |
重导出
一般, 一个模块会扩充其他模块, 然后有选择性地导出它们的一些功能.
重导出不先将模块导入本地, 或引入本地变量.
ParseIntBasedZipCodeValidator.ts
1 | export class ParseIntBasedZipCodeValidator { |
如果你愿意的话, 可以用 export * from "module"
语法打包导出一个或多个模块的功能.
AllValidators.ts
1 | export * from "./StringValidator"; // exports 'StringValidator' interface |
导入
导入差不多与从模块导出一样简单.
导入一个导出符号以以下任一 import
形式完成:
从模块导入单一符号
1 | import { ZipCodeValidator } from "./ZipCodeValidator"; |
导入符号也允许被重命名
1 | import { ZipCodeValidator as ZCV } from "./ZipCodeValidator"; |
导入整个模块到一个变量, 借助该变量访问模块的导出
1 | import * as validator from "./ZipCodeValidator"; |
为副作用而导入模块
有些模块设置全局状态以影响其他模块.
这样的模块通常没有任何导出, 或用户对它的导出不感兴趣.
尽管不推荐, 如果你想为副作用导入模块, 使用:
1 | import "./my-module.js"; |
默认导出
每个模块都可选择导出一个默认
导出.
默认导出用关键字 default
标注; 每个模块只能有一个默认
导出.默认
导出需要特殊的导入形式导入.
默认
导出很是方便.
举个例子, jQuery 等库可能有一个默认导出 jQuery
或 $
, 我们也很可能用名字 $ 或 jQuery
导入.
JQuery.d.ts
1 | declare let $: JQuery; |
App.ts
1 | import $ from "jquery"; |
类或函数定义可直接标注为默认导出.
默认导出的类和函数的名字是可选的.
ZipCodeValidator.ts
1 | export default class ZipCodeValidator { |
Test.ts
1 | import validator from "./ZipCodeValidator"; |
或
StaticZipCodeValidator.ts
1 | const numberRegexp = /^[0-9]+$/; |
Test.ts
1 | import validate from "./StaticZipCodeValidator"; |
默认
导出还可以仅是一个值:
OneTwoThree.ts
1 | export default "123"; |
Log.ts
1 | import num from "./OneTwoThree"; |
export =
和 import = require()
CommonJS 和 AMD 都有囊括一个模块所有导出的 exports
对象的概念.
它们也允许用一个自定义对象替代 exports
对象.
默认导出意图作为该功能的替代; 不过两者互不兼容.
TypeScript 提供了 export =
语法来模拟 CommonJS 和 AMD 工作流.
export =
语法指定一个从模块导出的对象.
它可以是类, 接口, 名字空间, 或枚举.
用 export =
导出一个模块后, 必须借助 TypeScript 特有的 import module = require("module")
导入它.
ZipCodeValidator.ts
1 | let numberRegexp = /^[0-9]+$/; |
Test.ts
1 | import zip = require("./ZipCodeValidator"); |
代码生成 — 模块
取决于在编译期指定的模块系统, 编译器会为 Node.js (CommonJS), require.js (AMD), UMD, SystemJS, 或 ECMAScript 2015 本地模块 (ES6) 模块加载系统生成合适的代码.
如要了解生成代码中 define
, require
, 和 register
调用的作用, 单独查询每个模块加载器的文档.
这个例子展示导入导出中使用的名字怎样转换成模块加载代码.
SimpleModule.ts
1 | import m = require("mod"); |
AMD / RequireJS SimpleModule.js
1 | define(["require", "exports", "./mod"], function (require, exports, mod_1) { |
CommonJS / Node SimpleModule.js
1 | var mod_1 = require("./mod"); |
UMD SimpleModule.js
1 | (function (factory) { |
System SimpleModule.js
1 | System.register(["./mod"], function(exports_1) { |
ECMAScript 2015 本地模块 SimpleModule.js
1 | import { something } from "./mod"; |
一个实例
下面, 我们通过只导出每个模块的一个命名符号来加强先前 Validator 的实现.
编译时, 在命令行参数指定所需模块系统. 对于 Node.js, 这参数是 --module commonjs
;
对 require.js, 则是 --module amd
. 请看示例:
1 | tsc --module commonjs Test.ts |
经过编译, 每个模块生成各自的 .js
文件.
类似于 reference 标签, 编译器会遵循 import
语句编译每个依赖文件.
Validation.ts
1 | export interface StringValidator { |
LettersOnlyValidator.ts
1 | import { StringValidator } from "./Validation"; |
ZipCodeValidator.ts
1 | import { StringValidator } from "./Validation"; |
Test.ts
1 | import { StringValidator } from "./Validation"; |
模块按需加载及其他高级加载场景
在某些情况下, 你可能希望满足一定条件才加载所需模块.
在 TypeScript 中, 你可以使用以下模式实现模块按需加载和其他高级加载场景, 在不损失类型安全的前提下直接调用模块加载器.
编译器检测输出的 JavaScript 是否使用了每个模块.
如果模块标识符总是出现在类型注解中, 从未在表达式中使用, 编译器便不会为这个模块生成相应的 require
调用.
对未使用引用的消除能优化性能, 也实现了对这些模块的按需加载.
这模式的核心想法是 import id = require("...")
语句让我们能访问模块导出的类型.
在以下 if
块中, 对模块加载器的调用是动态的 (通过 require
).
这里运用了引用消除优化, 所以模块只在必要时加载.
要使这模式奏效, 用 import
定义的标识符只能用在类型信息位置 (该位置不会输出至 JavaScript).
为了维护类型安全, 我们可以利用 typeof
关键字.
在类型信息位置使用 typeof
关键字, 将产生一个值的类型, 此例是输出模块的类型.
Node.js 动态模块加载
1 | declare function require(moduleName: string): any; |
实例: require.js 动态模块加载
1 | declare function require(moduleNames: string[], onLoad: (...args: any[]) => void): void; |
实例: System.js 动态模块加载
1 | declare const System: any; |
与其他 JavaScript 库协同工作
要描述非 TypeScript 库的形体, 我们需要声明该库暴露的 API.
我们称没有定义实现的声明为 “外部的”.
按照惯例, 把这些声明放置在 .d.ts
文件中.
如果你熟悉 C/C++, 它们相当于 .h
文件.
一起来看些例子.
外部模块
在 Node.js 中, 多数任务都要加载一个或多个模块才能完成.
我们可以声明顶层导出, 为每个模块建立专有的 .d.ts
文件, 但把所有导出声明放到一个大 .d.ts
文件更易使用.
我们采用与外部名字空间差不多的结构达成目的, 组合 module
关键字和以引号引起来的模块名, 提供对导入有用的信息.
node.d.ts (摘要)
1 | declare module "url" { |
此时, 先以 /// <reference>
node.d.ts
添加引用, 然后用 import url = require("url")
或 import * as URL from "url"
加载模块.
1 | /// <reference path="node.d.ts"/> |
原始外部模块
如果你不愿在使用一个新模块之前, 花时间编写模块声明, 原始外部模块让你快速开始.
declarations.d.ts
1 | declare module "hot-new-module"; |
所有来自原始外部模块的导入的类型都是 any
.
1 | import x, {y} from "hot-new-module"; |
带通配符的模块声明
1 | declare module "*!text" { |
现在你可以导入匹配 "*!text"
或 "json!*"
的定义了.
1 | import fileContent from "./xyz.txt!text"; |
UMD 模块
很多库设计为能为多个模块加载器所加载, 也有的不需要模块加载 (全局变量).
它们统称为 UMD 模块.
这些库可以通过导入语句或全局变量访问.
请看下例:
math-lib.d.ts
1 | export function isPrime(x: number): boolean; |
随后, 在模块中, 用 import 导入这个库:
1 | import { isPrime } from "math-lib"; |
它也可以作为全局变量访问, 但只有脚本文件允许.
(脚本文件是不包含任何 import, export 语句的源文件)
1 | mathLib.isPrime(2); |
模块组织指南
导出尽可能靠近顶层
你模块的用户在使用你模块导出时阻力应越小越好.
向模块添加过多嵌套层次等于给用户制造麻烦, 在组织模块结构时必须深思熟虑.
从模块导出名字空间便是添加过多嵌套层次的反面教材.
在使用模块时, 名字空间为它添加了额外的一层间接访问, 有时是有用的.
但它很容易成为用户痛点, 同时很难说是必要的.
导出类中的静态方法也存在同样的问题 - 类本身增加了额外的一层嵌套.
除非为了可读性考虑, 或有清晰的意图, 导出一个简单的帮助函数(helper function)更好.
导出单个类
或函数
, 别忘记默认导出
出于对”‘导出靠近顶层’能减轻模块用户使用阻力”这一原则的认同, 我们才有默认导出.
如果一个模块仅包含一特定功能, 你应该考虑将它默认导出.
这使得以后不论是导入或实际使用都更简单.
举个例子:
MyClass.ts
1 | export default class SomeType { |
MyFunc.ts
1 | export default function getThing() { return "thing"; } |
Consumer.ts
1 | import t from "./MyClass"; |
这是对用户最理想的方法. 他们可以按自己喜好给你的类型起名 (本例是 t
), 同时不需过多点运算去寻找你的对象.
导出多个对象, 把它们全置于顶层
MyThings.ts
1 | export class SomeType { /* ... */ } |
导入时:
显式列出导入名
Consumer.ts
1 | import { SomeType, someFunc } from "./MyThings"; |
在导入大量目标时运用名字空间导入模式
MyLargeModule.ts
1 | export class Dog { ... } |
Consumer.ts
1 | import * as myLargeModule from "./MyLargeModule.ts"; |
重导出以扩充
有时你需要扩充一个模块的功能.
用扩展增强原始对象是种 JS 设计模式, 与 JQuery 扩展的工作方式相仿.
前文已提到, 模块不像全局名字空间会跨文件合并.
受推荐的解决方案是不修改原始对象, 相反, 导出提供新功能的另一实体.
考虑定义在 Calculator.ts
模块中的计算器实现.
该模块同时导出一个帮助函数, 它接受一个输入字符串列表, 在最后打印结果, 以测试计算器功能.
Calculator.ts
1 | export class Calculator { |
下面给出一个调用 test
函数对计算器的测试.
TestCalculator.ts
1 | import { Calculator, test } from "./Calculator"; |
现在, 扩充这个模块, 让它接受不以 10 为底的进制数作为输入, 创建新文件 ProgrammerCalculator.ts
ProgrammerCalculator.ts
1 | import { Calculator } from "./Calculator"; |
新模块 ProgrammerCalculator
导出与原始 Calculator
模块相同的 API , 但没有增强原始模块中任何对象.
针对 ProgrammerCalculator 类的测试如下:
TestProgrammerCalculator.ts
1 | import { Calculator, test } from "./ProgrammerCalculator"; |
不要在模块中使用名字空间
首次转移到基于模块的组织结构的人, 都倾向把导出符号包裹在额外的名字空间层中.
模块有自己独立的空间, 只有导出的声明才对模块外部可见.
以此为前提, 与模块一并使用, 名字空间即便还有价值, 也是极其有限的.
模块以先, 名字空间能够在全局空间内给逻辑上相关的对象和类型分组.
比如说, 在 C# 中, 你会在 System.Collections 名字空间找到所有与集合相关的类型.
分层次把我们的类型组织在名字空间中, 用户在这些类型上可获得某种”探索”体验.
另一方面, 模块, 存在于文件系统.
我们通过文件路径和文件名找到他们, 这本来就是一种逻辑组织形式.
你可以创建 /collections/generic/ 文件夹, 存放所有列表相关模块.
在全局空间中, 名字空间是避免命名冲突的重要手段.
比如, 你可以定义 My.Application.Customer.AddForm
和 My.Application.Order.AddForm
— 两个名字一样, 所属名字空间不一样的两个类型.
而对于模块, 没必要担心命名冲突.
在一个模块内部, 出现两个名字一样的对象本身就不合理.
站在用户一端, 任一给定模块的用户会起一个它们用来引用该模块的名称, 避免了意外发生命名冲突.
阅读名字空间和模块获得更多相关讨论.
禁忌
以下两点都是用模块组织的禁忌. 如果你的文件满足任意一条, 双重确认你没使用名字空间:
- 它唯一顶层导出是
export namesapce Foo { ... }
(移除Foo
, 所有东西’往上’移动一层) - 多个文件包含相同顶层
export namespace Foo
(别期望它们会合并成一个Foo
!)