模块
在实际项目中,所有的代码不可能只放到一个文件中,而会存放到多个文件里,这包括项目自身的代码及引入的第三方代码。一个大型项目通常会涉及成百上千个 JavaScript 文件或 TypeScript 文件,它们之间存在互相引用的关系。
在 TypeScript 中,通过模块实现文件的互相引用。
由于模块具有不同的编译形式及运行方式,比较复杂,因此本节先介绍模块的概念及用法,然后再介绍如何以不同的形式编译(AMD/CMD/UMD/ECMAScript 6),以及如何在不同的平台上(浏览器或 Node.js)运行模块脚本,读者可以在阅读 13.1.4 节后再编译并运行本节中的各个代码示例。
导出模块
默认情况下,将一个文件视作一个模块。若要使其他文件能引用该模块的功能,你必须先使用 export 关键字将该文件标记为模块并导出指定声明。其他文件使用 import 关键字导入这些声明之后,就能够使用该模块的内容。
只要文件中有 export 关键字,浏览器或 Node.js 就会自动将该文件视为模块。使用 export 关键字可以导出指定声明,未使用 export 关键字导出的声明将无法在其他文件中使用。下面介绍 export 关键字的不同用法。
-
命名行内导出
命名行内导出即在变量、函数、类等进行声明时就导出,export 关键字与声明语句位于同一行。例如,以下代码均属于命名行内导出。
export let x: number = 11; export const y: string = "abc"; export function sayHello() { console.log("hello!"); } export class A { name: string } export interface B { sayHello: () => {} } export type NumberOrString = number | string;
-
命名子句导出
虽然我们可以使用命名行内导出的形式导出模块,但是这会使 export 语句散落在代码的各个位置,使人无法在第一时间知晓所有导出的内容,不利于代码的维护。
使用命名子句导出就可以解决这些问题。命名子句导出的语法如下,即在花括号中包含多个声明,进行批量导出。
export {声明1,声明2,...,声明n}
例如,以下代码先声明了一个变量 x,一个常量 y,以及一个函数 sayHello(),然后通过命名子句导出的形式将这些声明批量导出。
let x: number = 11; const y: string = "abc"; function sayHello() { console.log("hello!"); } export { x, y, sayHello }
在一个代码文件中可以有多处命名子句导出。例如,以下代码先导出变量 x,再导出常量 y及函数 sayHello()。
export { x } export { y, sayHello }
注意,不能重复导出同一个声明,否则会引起编译错误。示例代码如下。
//编译错误:标识符"x"重复导出。ts(2300) export { x } export { x }
使用命名子句导出还可以为导出的声明指定别名,这样其他文件在导入这些声明时必须使用指定的别名,而非原始声明名称。例如,以下代码在导出时为 x 指定了别名 myX,为 y 指定了别名 myY,为 sayHello() 函数指定了别名 mySayHello()。
let x: number = 11; const y: string = "abc"; function sayHello() { console.log("hello!"); } export { x as myX, y as myY, sayHello as mySayHello }
-
默认导出
还可以为模块指定默认导出。如果其他文件在导入该模块时未指定具体声明,则会使用该模块的默认导出。一个模块只有一个默认导出。
默认导出使用 default 关键字,有两种导出语法。
export default 指定声明 export {指定声明 as default}
默认导出的示例代码如下。
let x: number = 11; export default x;
以上示例代码也可以使用以下方式导出。
export {x as default}
同前面介绍的普通导出方式一样,TypeScript 支持的所有类型的声明均可以使用默认导出。
导出函数的示例代码如下。
export default function sayHello() { console.log("hello!"); }
导出类的示例代码如下。
export default class A { name: string }
导出接口的示例代码如下。
export default interface B { sayHello: () => {} }
导出类型别名的示例代码如下。
type NumberOrString = number | string; export default NumberOrString
导出字面量的示例代码如下。
export default "";
在使用默认导出时,注意,一个模块只能有一个默认导出,否则将引起编译错误。示例代码如下。
let x: number = 11; function sayHello() { console.log("hello!"); } //编译错误:一个模块不能具有多个默认导出。ts(2528) export default x; export default sayHello;
可以将其中一个声明作为默认导出,另一个声明作为常规导出。示例代码如下。
export {x as default, sayHello}
-
空导出
如果需要将一个文件标记为模块,以便其他文件引用,但并不对外暴露任何声明,或者只想让当前模块拥有隔离的作用域,可以对模块进行空导出。空导出语法如下。
export {}
当其他文件导入此模块时,会执行该模块的代码,但不会使用该模块的声明。
使用被导出的模块
模块一旦导出,就可以在另一个模块中使用。
假设现在导出的模块位于 a.ts 文件中,该文件内容如下,其中分别导出了 a、b、c、d这 4 个变量,变量 b 为该模块的默认导出,使用别名导出变量 c。
let a = "a";
let b = "b";
let c = "c";
export let d = "d";
export { a, b as default, c as myC }
下面将以 a.ts 文件的模块为例,介绍模块的使用方式。
以常规形式导入模块
通过 import 关键字导入从其他模块中导出的声明。
-
选择性地导入其他模块导出的声明
常规导入的语法如下。
import {声明1, 声明2,...,声明n} from "模块路径";
导入的声明可以直接在当前模块中使用其他模块中的声明。
假设当前模块位于 b.ts 文件中,它与 a.ts 文件位于同一目录,b.ts 文件的示例代码如下,其中导入并使用 a.js 文件(a.ts 文件编译后为 a.js 文件)。注意,变量 c 在导出时使用别名 myC,因此变量 c 在导入时必须使用该别名。
import { a, d, myC } from "./a.js"; console.log(a); //输出"a" console.log(myC); //输出"c" console.log(d); //输出"d"
导入模块时可以使用别名,这样就能既支持非默认导出声明的导入,又支持默认导出声明的导入,导入语法如下。当导入默认导出声明时,你必须使用 “default as声明别名” 的方式来重命名。
import {声明1 as 别名1, 声明2 as 别名2,...声明n as 别名n} from "模块路径";
例如,以下代码导入了 a.ts 文件中的各个声明,并分别使用别名进行重命名,然后输出各个声明的值。注意,变量 c 在导出时使用了别名 myC,因此在导入时必须使用该别名,然后进行重命名,而对于默认导出声明,则需要使用 default 关键字,然后进行重命名。
import { a as otherA, d as otherD, myC as otherC, default as otherB } from "./a.js"; console.log(otherA); //输出"a" console.log(otherB); //输出"b" console.log(otherC); //输出"c" console.log(otherD); //输出"d"
-
导入其他模块的默认导出声明
可以单独导入其他模块的默认导出声明,导入语法如下。其中,不需要使用花括号。可以将默认导出的声明导入为任何名称。
import 声明别名 from "模块路径";
例如,以下代码从 a.ts 文件中导入了默认导出声明(即 a.ts 文件中的变量 b),并将其命名为 moduleADefault,之后就可以直接使用了。
import moduleADefault from "./a.js"; console.log(moduleADefault); //输出 "b"
-
导入整个模块
还可以使用以下语法导入整个模块,之后就可以通过 “模块别名.声明” 的方式使用该模块中所有已导出的声明。
import * as 模块别名 from "模块路径";
例如,以下代码将整个模块导出为别名 moduleA,之后使用 module.a 和 moduleA.d 访问该模块中导出的声明。注意,由于变量 c 在导出时使用了别名,因此导入时也需要使用别名,即用 moduleA.myC 来访问,而对于默认导出声明,则需要使用 default 关键字,即用 moduleA.default 来访问。
import * as moduleA from "./a.js"; console.log(moduleA.a); //输出"a" console.log(moduleA.default); //输出"b" console.log(moduleA.myC); //输出"c" console.log(moduleA.d); //输出"d"
转移导出
除导入其他模块之外,还可以转移其他模块的导出,从其他模块引入导出声明,并将这些声明在当前模块中导出。
转移导出语法和导入语法基本一致,但是需要把 import 关键字换成 export 关键字,语法如下。
export {声明1, 声明2,...,声明n} from "模块路径";
export {声明1 as 别名1, 声明2 as 别名2,...声明n as 别名n} from "模块路径";
export 声明别名 from "模块路径";
export * as 模块别名 from "模块路径";
假设当前模块位于 b.ts 文件中,它与 a.ts 文件位于同一目录,b.ts 文件的示例代码如下,它对 a.js 文件(a.ts 文件编译后为 a.js 文件)中导出的声明 a 进行转移导出。
export {a} from './a.js'
在另一个文件(如在 c.ts 文件)中,就可以导入并使用 b.ts 文件中导出的声明 a。示例代码如下。
import { a } from './b.js'
console.log(a); //输出"a"
转移导出并没有在当前模块导入其他模块的声明,因此在当前模块中无法使用这些声明,如果 b.ts 文件的代码如下,将会引起编译错误。 |
export {a} from './a.js'
//编译错误:找不到名称"a"。ts(2304)
console.log(a);
动态导入
import 关键字用于导入其他模块导出的声明,它是一种静态导入语法,只能位于代码顶层作用域,每次运行当前模块时,都会加载所有待导入模块的内容。使用静态导入语法无法根据条件进行动态导入,例如,以下代码将引起编译错误。
if (x == 123) {
//编译错误:导入声明只能在命名空间或模块中使用。ts(1232)
import { a } from "./a.js";
}
如果需要按一定条件或者按需动态加载模块,则可以使用 ECMAScript 6 中新增的 import() 函数。动态导入只支持导入整个模块。
动态加载是一种异步编程方式(关于异步编程的内容可参考第 15 章),例如,如果要动态导入的是 a.ts 文件(编译后为 a.js 文件),那么对 b.ts 文件可以使用以下两种形式编写代码。
以 ECMAScript 6 形式(Promise 对象)动态导入模块,示例代码如下。
import("./a.js").then((module) => {
console.log(module.a);
console.log(module.default);
console.log(module.myC);
console.log(module.d);
});
以 ECMAScript 7 形式(async/await 语法)动态导入模块,示例代码如下。
let module = await import('./a.js');
console.log(module.a);
console.log(module.default);
console.log(module.myC);
console.log(module.d);
动态导入可以嵌套在非顶级作用域的代码块(如 if 语句或函数)中,示例代码如下。
if (x = 123) {
let module = await import('./a.js');
console.log(module.a);
}
无论以 ECMAScript 6 形式还是 ECMAScript 7 形式动态导入模块,在编译时都需要指定 target 和 module 版本(例如,指定为最新版,即 esnext),否则 ECMAScript 编译将无法正常完成。编译命令如下。
$ tsc b.ts --target esnext --module esnext
空导入
在有些场景下,导入某个模块并不需要使用该模块的任何声明,只需要完整地运行一次该模块的代码,因此就可以使用空导入。空导入的语法如下。
import "模块路径"
例如,a.ts 的内容如下。
let a = 1;
console.log(`a is ${a}`);
globalThis.X = "hello";
以上代码导出一个变量 a,并输出 a 的值,然后使用全局对象 globalThis,将它的 X 属性指定为 "hello"。
然后,在 b.ts 文件中进行空导入,a.ts 的代码将被执行,因此会立即输出变量 a 的值,之后再输出全局对象 globalThis 的 X 属性的值,可以发现它的值正是之前在 a.ts 文件中设置的值。
import "./a.js" //输出"a is 1"
console.log(globalThis.X); //输出"hello"
导入与导出TypeScript类型声明
由于 TypeScript 代码最终会编译成 JavaScript 代码,因此那些 TypeScript 独有的只用于编译检查的类型代码(如变量的类型声明、接口的声明与使用、类型别名的声明与使用等)会在编译过程中被剔除。
当只导入 TypeScript 独有的只用于编译检查的类型代码时,.ts
文件代码看上去已经使用了 import 语句,但编译后的 .js
文件会剔除 import 语句,改用空导出代替,因此可能会产生一些意想不到的问题。
例如,在 a.ts 文件中声明并导出一个接口 A,然后使用全局对象 globalThis,将它的 X 属性赋值为 "hello"。
interface A { x: string }
export { A };
console.log("good");
globalThis.X = "hello";
在 b.ts 文件中导入 a.ts 文件中导出的接口声明,此导入有两个目的:一是使用 A 接口,二是执行 a.ts 文件中的代码,然后再输出全局对象 globalThis 的 X 属性的值。示例代码如下。
//导入a.ts有两个目的,一是使用A接口,二是执行a.ts文件中的代码
import { A } from './a.js'
let a: A = { x: "hello" };
console.log(globalThis.X);
若只看以上代码,会认为在 b.ts 文件中导入 a.ts 文件时,会先执行 a.ts 文件中的代码,输出 good,然后读取 golbalThis.X 的值,输出 hello。如果查看编译后的代码,就会发现结果和预期并不相同。
a.ts 文件编译后产生的 a.js 文件如下。
console.log("good");
globalThis.X = "hello";
export {};
b.ts 文件编译后产生的 b.js 文件如下。可以发现编译后的 .js
文件已剔除了 import 语句,改用空导出代替。
let a = { x: "hello" };
console.log(globalThis.X);
export {};
当执行 b.js 文件时,由于 b.js 文件并未导入 a.js 文件,因此 a.js 文件中的代码并不会执行,既不会输出 “good”,也不会对 globalThis.X 属性赋值,因此 b.js 文件中代码的执行结果如下。
> undefined
要避免这种情况,可以使用 TypeScript 中提供的类型导出与导入,语法如下。
export type {类型声明1,类型声明2,…}
import type {类型声明1,类型声明2,…} from '模块路径'
export type/import type 语句与 export/import 语句并无实质区别,但前者能提高代码的可读性和可维护性。export type/import type 语句最大的作用是将类型导出/导入与普通声明的导出/导入拆分成不同的代码行,从而可以分开进行维护,避免产生意想不到的问题。
将前面的示例改为类型导出后,a.ts 的代码如下。
interface A { x: string }
export type { A };
console.log("good")
globalThis.X = "hello";
b.ts 的代码如下,根据导入 a.ts 文件的两个目的分离成不同的代码行。
//目的1:使用A接口
import type { A } from './a.js'
//目的2:执行a.ts的代码
import './a.js'
let a: A = { x: "hello" };
console.log(globalThis.X);
以上代码编译后,a.js 文件没有变化,但由于 b.ts 文件进行了一次对 a.js 文件的空导入,因此 b.js 文件会发生变化,代码如下。
import './a.js';
let a = { x: "hello" };
console.log(globalThis.X);
执行 b.js,结果将符合预期,输出结果如下。
> good
> hello
导入或导出模块时的注意事项
每个模块拥有各自独立的作用域
在未使用 export/import 关键字将文件标记为模块前,多个文件的声明在同一个作用域中。如果两个文件具有同名的声明,将出现编译错误。例如,有 a.ts 和 b.ts 两个文件,它们位于同一个目录下,且具有同名变量,这将引起编译错误。
a.ts 文件位于 D:\tsproject 目录下,文件的代码如下,这会引起编译错误。
//编译错误:无法重新声明块范围变量"a"。ts(2451) a.ts(1, 5): 此处也声明了 "a"
let a: number = 11;
b.ts 文件位于 D:\tsproject 目录下,文件的代码如下,这也会引起编译错误。
//编译错误:无法重新声明块范围变量"a"。ts(2451) b.ts(1, 5): 此处也声明了 "a"。
let a: number = 1;
一旦使用 export 关键字将文件标记为模块,则每个模块都将被视为互相隔离的作用域,即使各个模块拥有同名变量,也不会互相影响。
a.ts 文件的代码如下,编译正常。
export let a: number = 1;
b.ts 文件的代码如下,编译正常。
let a: number = 11;
使用 import 关键字同样会将文件标记为模块,如同 export 关键字一样,每个模块都将被视为互相隔离的作用域。即使各个模块拥有同名变量,也不会互相影响。
a.ts 文件的代码如下,编译正常。
import ""
let a: number = 1;
b.ts 文件的代码如下,编译正常。
let a: number = 11;
import/export 语句必须位于模块的顶级
export/import 语句必须位于模块的顶级,不能嵌套在代码块中,否则将引起编译错误。示例代码如下。
export let x = 123;
if (x == 123) {
//编译错误:修饰符不能出现在此处。ts(1184)
export let y = "abc";
}
function test() {
//编译错误:修饰符不能出现在此处。ts(1184)
export let c = true;
}
if (x == 123) {
//编译错误:导入声明只能在命名空间或模块中使用。ts(1232)
import { a } from "./a.js";
}
function test2() {
//编译错误:导入声明只能在命名空间或模块中使用。ts(1232)
import { a } from "./a.js";
}
编译与运行模块
对于模块,TypeScript 有多种编译方式,每种编译方式产生的 JavaScript 代码各不相同,运行方式也有所区别,这都与 JavaScript 的模块规范有关。
不同时期的模块规范如下。
-
CommonJS 规范。
-
异步模块定义(Asynchronous Module Definition,AMD)规范。
-
通用模块定义(Universal Module Definition,UMD)规范。
-
ECMAScript 规范(这是最新的规范,将替代以往的所有规范)。
假设现在有两个文件,分别为 a.ts 和 b.ts。
a.ts 的代码如下。
export { a, test }
let a = "hello";
function test() { return "world"; }
b.ts 的代码如下。
import { a, test } from './a.js'
console.log(a);
console.log(test());
接下来,将按照模块规范的发展顺序,依次介绍不同的模块规范,讨论如何将 a.ts 和 b.ts 文件编译为指定模块规范的 JavaScript 代码,并讲述各个模块规范的代码在不同平台(浏览器或 Node.js)上如何运行。
CommonJS规范
JavaScript 原本只能在浏览器端使用,应用范围有限,要实现服务器端的功能,你必须换一种语言。2009年,Ryan Dahl 基于 Chrome 的 JavaScript 来源引擎(V8 引擎)开发并发布了 Node.js 运行环境,使 JavaScript 能够在服务器端运行。自此,JavaScript 成为一门服务器端语言,如同 PHP、Python、Perl、Ruby 等服务器端语言一样。
在浏览器中,由于网页的复杂性有限(此时 Ajax 还不流行),因此不使用模块也可以,ECMAScript 标准还没有提出模块化的相关规范。
但对于服务器端来说,代码不仅要复用,还要与操作系统或其他接口进行交互,各个代码文件的关联性较强,若不进行模块化管理,根本无法维护。
因此,Node.js 推出了它的模块系统,该模块系统遵循 CommonJS 规范。自此以后,JavaScript 正式开始了模块化编程。
-
按 CommonJS 规范编译代码
CommonJS 是 TypeScript 在编译带有模块的代码时的默认规范,如果没有设置配置文件(tsconfig.json,后面会详细介绍),编译命令中可以不带 --module 参数来特别指明模块规范。以下两种编译方式都可以将 TypeScript 代码编译为满足 CommonJS 规范的 JavaScript 代码。
tsc文件路径 tsc文件路径 --module commonjs
接下来,介绍按 CommonJS 规范编译后产生的 a.js 和 b.js 文件。
以下是 a.js 的代码。
"use strict"; exports.__esModule = true; exports.test = exports.a = void 0; var a = "hello"; exports.a = a; function test() { return "world"; } exports.test = test;
以下是 b.js 的代码。
"use strict"; exports.__esModule = true; var a_js_1 = require("./a.js"); console.log(a_js_1.a); console.log(a_js_1.test());
-
在Node.js中运行基于CommonJS规范的代码
由于 Node.js 天然支持 CommonJS规范,因此按 CommonJS 规范编译的 JavaScript 代码能够直接运行,我们可以用命令直接运行 b.js 文件。
$ node b.js
输出结果如下。
> hello > world
-
在浏览器中运行基于CommonJS规范的代码
在 Node.js 服务器端支持模块后,开发人员希望在浏览器端也能使用模块,这样同一套模块就可以同时在服务器和浏览器上运行。但是,在浏览器环境中使用 CommonJS 规范存在两个问题。第一个问题是 Node.js 是原生支持 CommonJS 模块的,而浏览器并不支持 CommonJS 模块,因此需要引入第三方 JavaScript 库文件,用来支持 CommonJS 规范中的语法。
第二个问题是加载时间。例如,以 CommonJS 规范编译出的 b.js 文件通过 require() 函数加载模块,这是一种同步加载的形式,因此后面的代码必须等 require() 函数加载完 a.js 文件后才会执行,如果加载时间较长,则应用程序将一直等待,界面将被阻塞。示例代码如下。
...
var a_js_1 = require("./a.js");
console.log(a_js_1.a);
console.log(a_js_1.test());
在服务器端并不存在这个问题,所有模块的 .js
文件都存在本地硬盘上,同步加载时间为硬盘读取时间。但对于浏览器来说,所有模块的 .js
文件都不在本地,需要从服务器端远程下载,等待时间取决于网速,因此程序执行会变得不稳定,甚至出现卡死的情况。
要在浏览器端使用 CommonJS 规范,通常的办法是将多个互相依赖的 JavaScript 模块文件和用于支持 CommonJS 规范的 JavaScript 库文件合并成单个 JavaScript 文件并进行压缩,然后浏览器通过 <script> 标签引用这个合并后的 JavaScript 文件。
使用 JavaScript 打包工具 Browserify 将多个互相依赖的 JavaScript 模块文件以及用于支持 CommonJS 规范的 JavaScript 库文件合并成单个 JavaScript 文件,然后供浏览器使用。
Browserify 的安装命令如下。
$ npm install -g browserify
安装完成后,执行以下命令,将指定文件及其所有依赖文件(基于 require() 函数分析当前文件依赖了哪些文件)与支持 CommonJS 规范的 JavaScript 库合并到一个 JavaScript 文件中。
$ browserify 合并前的主文件.js -o 合并后的文件名.js
在本例中将执行以下命令,其中主文件为 b.js,由于 b.js 的代码中有一句 require("./a.js"),因此打包工具会将其识别为主文件的依赖文件,a.js 的内容也会打包到合并后的 JavaScript 文件中。
$ browserify b.js -o bundle.js
合并后生成的 bundle.js 文件如下,它包含 a.js、b.js 文件,以及用于支持 CommonJS 规范的额外代码。
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"
==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new
Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]=
{exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},
p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,
i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){
"use strict";
exports.__esModule = true;
exports.test = exports.a = void 0;
var a = "hello";
exports.a = a;
function test() { return "world"; }
exports.test = test;
},{}],2:[function(require,module,exports){
"use strict";
exports.__esModule = true;
var a_js_1 = require("./a.js");
console.log(a_js_1.a);
console.log(a_js_1.test());
},{"./a.js":1}]},{},[2]);
之后就可以在浏览器中引用这个 .js
文件并执行了,例如,在 bundle.js 文件所在的目录下建立一个 HTML 文件,其内容如下。
<script src="bundle.js"></script>
之后用浏览器打开此 HTML 页面,进入浏览器开发工具(快捷键通常为 F12),在控制台中可以看到输出结果。
> hello
> world
AMD规范
前面已经提到,在浏览器中执行 CommonJS 规范时经常会遇到各种问题。虽然打包方式能够缓解通过网络加载其他文件的速度问题,但是仍然需要完整下载打包后的文件。当依赖文件过多时,打包后的 JavaScript 文件将变得巨大,在下载完该文件之前,界面上的功能将无法使用,因此这种方式并未从根本上解决 CommonJS 规范在浏览器中执行时遇到的问题。
针对浏览器环境的特点,后来的开发者引入了 AMD 规范来支持浏览器模块化。AMD 规范采用异步方式加载各个模块,在加载模块时不影响后面语句的执行。依赖某个外部模块的语句会放在回调函数中,等模块加载完后才会执行回调函数。AMD 规范也采用 require() 函数来加载模块,但是和 CommonJS 规范相比略显复杂。在 CommonJS 规范中,require() 函数只需要向 require() 函数传入一个参数,即模块路径;而在 AMD 规范中,需要向该函数传入两个参数,一个是与模块信息相关的数组,另一个是模块加载完后的回调函数。
-
按AMD规范编译代码
要按 AMD 规范编译代码,编译命令中需要带有 --module 参数来特别指明模块规范,其命令如下。
$ tsc 文件路径 --module amd
以 AMD 规范编译后产生的 a.js 和 b.js 文件如下。
以下是 a.js 的代码。
define(["require", "exports"], function (require, exports) { "use strict"; exports.__esModule = true; exports.test = exports.a = void 0; var a = "hello"; exports.a = a; function test() { return "world"; } exports.test = test; });
以下是 b.js 的代码。
define(["require", "exports", "./a.js"], function (require, exports, a_js_1) { "use strict"; exports.__esModule = true; console.log(a_js_1.a); console.log(a_js_1.test()); });
-
在浏览器中运行基于AMD规范的代码
由于浏览器并不原生支持 AMD 模块,因此必须要引用第三方 JavaScript 库文件来支持 AMD 模块。
RequireJS 是一个非常小巧的 JavaScript 模块载入框架,它基于 AMD 规范实现,适用于各个浏览器。只需在 HTML 文件的 script 标签中引用 RequireJS 的最新版本,然后将 script 标签的 data-main 设置为主模块路径即可。当打开 HTML 页面时,会先加载 RequireJS,然后加载并执行主模块。当代码执行到依赖模块时,会动态加载依赖模块,加载完成后执行回调函数。
在本例中,在主模块——b.js 文件所在目录下新建一个 HTML 文件,其内容如下。
<script src="https://requirejs.org/docs/release/2.3.5/minified/require.js" data-
main="b.js"></script>
之后用浏览器打开此 HTML 页面,进入浏览器开发工具,在控制台中可以看到输出结果。
> hello
> world
UMD规范
由于 CommonJS 规范无法用于浏览器环境,而 AMD 规范无法用于 Node.js 环境,因此基于其中一种模块规范写出的一套代码无法同时在 Node.js 和浏览器中使用。为了避免 “重复造轮子”,开发者便引入了 UMD 规范来解决一套代码无法在不同环境中使用的问题。
UMD 规范多用于一些需要同时支持浏览器端和服务端引用的第三方 JavaScript 库。基于 UMD 规范的代码既可以在浏览器上以 AMD 形式使用,也可以在 Node.js 上以 CommonJS 形式使用。由于它需要兼容不同的环境,因此基于 UMD 规范的 JavaScript 代码编写起来相对复杂,可读性也相对较差。
-
按UMD规范编译代码
要按 UMD 规范编译代码,编译命令中需要带有 --module 参数来特别指明模块规范,其命令如下。
$ tsc 文件路径 --module umd
按 AMD 规范编译会产生的 a.js 和 b.js 文件。
以下是 a.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"], factory); } })(function (require, exports) { "use strict"; exports.__esModule = true; exports.test = exports.a = void 0; var a = "hello"; exports.a = a; function test() { return "world"; } exports.test = test; });
以下是 b.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", "./a.js"], factory); } })(function (require, exports) { "use strict"; exports.__esModule = true; var a_js_1 = require("./a.js"); console.log(a_js_1.a); console.log(a_js_1.test()); });
-
在 Node.js 中运行基于 UMD 规范的代码
由于基于 UMD 规范的代码可以以 CommonJS 形式运行,而 Node.js 天然支持 CommonJS 规范,因此在 Node.js 环境中可以用命令直接运行 b.js 文件。
$ node b.js
输出结果如下。
> hello > world
-
在浏览器运行基于UMD规范的代码
由于基于 UMD 规范的代码可以以 AMD 形式运行,因此在主模块——b.js文件所在目录下建立一个 HTML 文件,其内容如下。
<script src="https://requirejs.org/docs/release/2.3.5/minified/require.js" data-main="b.js"></script>
之后用浏览器打开此 HTML 页面,进入浏览器开发工具,在控制台中可以看到输出结果。
> hello > world
ECMAScript 6规范
前面介绍的 CommonJS、AMD 及 UMD 规范都并非 ECMAScript 官方规范。在 ECMAScript 正式制定模块规范前,由于业界对模块化管理的迫切需求,因此一些第三方团队基于 JavaScript 的语法制定了 CommonJS、AMD 及 UMD 规范,并开发了 JavaScript 框架,以模拟出模块的行为。
随着 ECMAScript 规范的不断发展,在 ECMAScript 6(ECMAScript 2015) 中终于拟定了官方模块规范,所有 JavaScript 平台都支持基于该规范的模块(目前 Node.js 支持 ECMAScript 6 规范的模块,Chrome、Firefox、Edge 等主流浏览器也支持该规范),前面的 CommonJS、AMD、UMD 规范将逐渐退出历史舞台。
本章介绍的 TypeScript 模块导入/导出示例就是使用 ECMAScript 6 规范编写的。
-
按ECMAScript 6规范编译代码
本章的 TypeScript 模块示例代码都是以 ECMAScript 6 规范编写的,它们可以编译为同样使用 ECMAScript 6 规范的 JavaScript 代码,编译命令中需要带 --module 参数来特别指明模块规范,命令如下。
$ tsc a.ts --module esnext
以下是编译后生成的 a.js 的代码,代码和 a.ts 的一致。
export { a, test }; var a = "hello"; function test() { return "world"; }
以下是编译后生成的 b.js 的代码,代码和 b.ts 的一致。
import { a, test } from './a.js'; console.log(a); console.log(test());
-
在Node.js中运行基于ECMAScript 6规范的代码
由于在 Node.js 中默认使用的是基于 CommonJS 规范的模块,因此直接在 Node.js 中运行 b.js 的代码,会出现以下提示。
Process exited with code 1 (node:14768) Warning: To load an ES module, set "type": "module" in the package. json or use the .mjs extension.
要在 Node.js 中运行基于 ECMAScript 6 规范的代码,有以下两种方法。
-
分别更改 a.js 和 b.js 的后缀名为 a.mjs 与 b.mjs,并且使用
import { a, test } from './a.mjs'
在 b.mjs 中更改导入语句的模块路径,之后执行命令node b.mjs
来运行代码。 -
在 a.js 和 b.js 所在的目录中,运行命令 “npm init”,根据提示输入并确认信息(当提示输入 entry point 时,输入 b.js,它将作为程序入口),确认完成后,会在该目录下创建 package.json 文件。打开 package.json 文件进行编辑,增加一行配置 "type": "module"(该配置有两个值——module 和 commonjs,当取值为 module 时,表示执行模块代码时默认使用 ECMAScript 6 规范),编辑后的 package.json 文件如下。
{ "name": "tsproject", "version": "1.0.0", "description": "", "main": "b.js", "scripts": { "test": "echo \"Error: no test specified\" && exit 1" }, "author": "", "license": "ISC", "type": "module" }
之后执行命令 “node b.js” 来运行代码。
-
-
在浏览器中运行基于 ECMAScript 6 规范的代码
要在浏览器中运行基于 ECMAScript 6 规范的代码,在主模块 (b.js) 所在目录下建立一个 HTML 文件,其内容如下。和前面示例代码的不同之处在于,要运行基于 ECMAScript 6 规范的代码,必须指定 script 标签的 type 属性值为 module,代码如下。
<script type="module" src="b.js"></script>
但是,如果直接以本地文件的形式用浏览器打开此 HTML 页面,在浏览器开发工具中就会看到以下错误提示。
Access to script at 'file:///D:/TSProject/b.js' from origin 'null' has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, chrome, chrome-extension, chrome-untrusted, https. b.js:1 Failed to load resource: net::ERR_FAILED
这是浏览器跨域安全策略导致的。要解决这个问题,在本地架设 Web 服务器,通过使用浏览器访问 Web 服务器的形式获取此 HTML 页面。
live-server 是一个具有实时加载功能的小型服务器工具,通过它架设临时 Web 服务器。首先,执行以下命令,安装 live-server。
$ npm install -g live-server
安装完成后,在 HTML 页面所在目录下启动 live-server 服务器,命令如下。
$ live-server
服务器启动后,会默认使用 8080 端口架设服务器。此时,用浏览器访问本机 8080 端口下的 HTML 页面,如图13-1所示。

进入浏览器开发工具,在控制台中可以看到输出结果。
> hello
> world
解析模块路径
在导入语句 import…from 中,根据模块路径,将导入分为相对模块导入和非相对模块导入两种类型。
相对模块导入
当模块路径以 “/”、“./” 或 “../” 前缀时,为相对模块导入,它以当前正在运行的模块在文件系统中的路径为基准寻找其他模块。不同模块路径前缀的含义如下。
-
“/” 表示磁盘根目录。如果当前文件位于 D:/folder1/subFolder1 文件夹下,import … from "/a.js" 表示从 D:/a.js 导入模块。
-
“./” 表示当前目录。如果当前文件位于 D:/folder1/subFolder1 文件夹下,import … from "./a.js" 表示从 D:/folder1/subFolder1/a.js 导入模块。
-
“../” 表示上一级目录。如果当前文件位于 D:/folder1/subFolder1 文件夹下,import … from "../a.js" 表示从 D:/folder1/a.js 导入模块,而 import…from "../../a.js" 表示从 D:/a.js 导入模块。
现在有以下几个文件。
D:\a.js
D:\folder1\b.js
D:\folder1\subfolder1\c.js
D:\folder1\subfolder1\d.js
D:\folder1\subfolder2\e.js
假设要在 a.js 文件中访问 b.js 和 c.js 文件,可以使用 import … from "./folder1/b.js" 来访问 b.js 文件,使用 import … from "./folder1/subfolder1/c.js" 来访问 c.js 文件。
假设要在 c.js 文件中访问其他所有 .js
文件,可以使用 import … from "/a.js" 或 import … from "../../a.js" 来访问 a.js 文件,使用 import … from "../b.js" 来访问 b.js 文件,使用 import … from "./d.js" 来访问 d.js 文件,使用 import … from "../subfolder1/c.js" 来访问 c.js 文件。
非相对模块导入
当模块路径不以 “/”、“./” 或 “../” 为前缀时,导入都属于非相对模块导入。示例代码如下。
import * as $ from "jQuery";
import { Component } from "@angular/core";
非相对模块导入并没有指出明确的模块路径,解析模块路径的规则与所使用的环境及框架有关,不同环境与框架的解析过程并不相同。注意,非相对模块导入规则并不符合 ECMAScript 6 规范,它是由具体运行环境或框架的提供方制定的,它只适用于 CMD/AMD 等第三方规范,与 ECMAScript 6 官方模块规范存在冲突,因此这里不做详述。对于非相对模块导入规则,有兴趣的读者可以参考 Node.js 和 TypeScript 官网。