模块

在实际项目中,所有的代码不可能只放到一个文件中,而会存放到多个文件里,这包括项目自身的代码及引入的第三方代码。一个大型项目通常会涉及成百上千个 JavaScript 文件或 TypeScript 文件,它们之间存在互相引用的关系。

在 TypeScript 中,通过模块实现文件的互相引用。

由于模块具有不同的编译形式及运行方式,比较复杂,因此本节先介绍模块的概念及用法,然后再介绍如何以不同的形式编译(AMD/CMD/UMD/ECMAScript 6),以及如何在不同的平台上(浏览器或 Node.js)运行模块脚本,读者可以在阅读 13.1.4 节后再编译并运行本节中的各个代码示例。

导出模块

默认情况下,将一个文件视作一个模块。若要使其他文件能引用该模块的功能,你必须先使用 export 关键字将该文件标记为模块并导出指定声明。其他文件使用 import 关键字导入这些声明之后,就能够使用该模块的内容。

只要文件中有 export 关键字,浏览器或 Node.js 就会自动将该文件视为模块。使用 export 关键字可以导出指定声明,未使用 export 关键字导出的声明将无法在其他文件中使用。下面介绍 export 关键字的不同用法。

  1. 命名行内导出

    命名行内导出即在变量、函数、类等进行声明时就导出,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;
  2. 命名子句导出

    虽然我们可以使用命名行内导出的形式导出模块,但是这会使 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 }
  3. 默认导出

    还可以为模块指定默认导出。如果其他文件在导入该模块时未指定具体声明,则会使用该模块的默认导出。一个模块只有一个默认导出。

    默认导出使用 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}
  4. 空导出

    如果需要将一个文件标记为模块,以便其他文件引用,但并不对外暴露任何声明,或者只想让当前模块拥有隔离的作用域,可以对模块进行空导出。空导出语法如下。

    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 关键字导入从其他模块中导出的声明。

  1. 选择性地导入其他模块导出的声明

    常规导入的语法如下。

    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"
  2. 导入其他模块的默认导出声明

    可以单独导入其他模块的默认导出声明,导入语法如下。其中,不需要使用花括号。可以将默认导出的声明导入为任何名称。

    import 声明别名 from "模块路径";

    例如,以下代码从 a.ts 文件中导入了默认导出声明(即 a.ts 文件中的变量 b),并将其命名为 moduleADefault,之后就可以直接使用了。

    import moduleADefault from "./a.js";
    console.log(moduleADefault); //输出 "b"
  3. 导入整个模块

    还可以使用以下语法导入整个模块,之后就可以通过 “模块别名.声明” 的方式使用该模块中所有已导出的声明。

    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 正式开始了模块化编程。

  1. 按 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());
  2. 在Node.js中运行基于CommonJS规范的代码

    由于 Node.js 天然支持 CommonJS规范,因此按 CommonJS 规范编译的 JavaScript 代码能够直接运行,我们可以用命令直接运行 b.js 文件。

    $ node b.js

    输出结果如下。

    > hello
    > world
  3. 在浏览器中运行基于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 规范中,需要向该函数传入两个参数,一个是与模块信息相关的数组,另一个是模块加载完后的回调函数。

  1. 按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());
    });
  2. 在浏览器中运行基于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 代码编写起来相对复杂,可读性也相对较差。

  1. 按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());
    });
  2. 在 Node.js 中运行基于 UMD 规范的代码

    由于基于 UMD 规范的代码可以以 CommonJS 形式运行,而 Node.js 天然支持 CommonJS 规范,因此在 Node.js 环境中可以用命令直接运行 b.js 文件。

    $ node b.js

    输出结果如下。

    > hello
    > world
  3. 在浏览器运行基于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 规范编写的。

  1. 按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());
  2. 在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” 来运行代码。

  3. 在浏览器中运行基于 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所示。

image 2024 02 19 13 13 27 430
Figure 1. 图13-1 用浏览器访问本机8080端口下的HTML页面

进入浏览器开发工具,在控制台中可以看到输出结果。

> 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 官网。