译文:TypeScript 模块

原文:TypeScript - Modules

JS 曾经有多种不同的方式来处理模块。在 2012 年时 TypeScript 就已经全面支持这些方式了。但后来又出现了 ES6 模块语法,也就是 import/export 语法。

ES6 模块在 2015 年被添加到 ECMAScript 中,到 2020 年时,大部分浏览器和 JS 运行时都支持这种语法。

本手册主要介绍 ES6 模块语法和 CommonJS 的 module.exports = 语法,其他语法可以去此处查看。

非模块

首先我们需要了解 JS 中的「非模块」是什么。

JavaScript 规范中说任何没有 export 或顶层 await 的 JavaScript 文件都只是一个脚本而不是一个模块。

在一个脚本文件中,变量和类型被声明在全局范围,并且默认你会把多个脚本编译成一个文件,或者你会在 HTML 中用 <script> 标签来加载这些文件。

如果你的文件里没有 importexport,但你又希望它被当做一个模块处理,那么你可以在代码中添加这一行:

export {};

这句话会让文件变成一个模块,仅此而已。

TS 中的模块

在 TS 中编写基于模块的代码时,有三个主要方面需要考虑:

  • 语法: 用什么语法来导入依赖或者导出某个东西?
  • 模块解析: 模块名(或模块路径)与硬盘上的文件有什么关联?
  • 模块输出目标: 输出成 ES6 模块还是 CommonJS 模块,还是其他格式呢?

语法

ESM 语法

export default 默认导出一个函数:

// @filename: hello.ts
export default function helloWorld() {
 console.log("Hello, world!");
}

然后就可以在其他文件导入了

import helloWorld from "./hello.js";
helloWorld();

你也可以不用默认导出,直接导出变量或函数:

// @filename: maths.ts
export var pi = 3.14;
export let squareTwo = 1.41;
export const phi = 1.61;
export class RandomNumberGenerator {}
export function absolute(num: number) {
 if (num < 0) return num * -1;
 return num;
}

然后在另外的文件中导入它们:

import { pi, phi, absolute } from "./maths.js";
console.log(pi);
const absPhi = absolute(phi);

更多 ESM 导入语法

你还可以用 import {old as new} 将导入的变量重命名:

import { pi as π } from "./maths.js";
console.log(π);

你还可以把两种 import 写在同一句:

// @filename: maths.ts
export const pi = 3.14;
export default class RandomNumberGenerator {}
// @filename: app.ts
import RandomNumberGenerator, { pi as π } from "./maths.js";
RandomNumberGenerator;
console.log(π);

你可以用 * as name 把导出的所有变量放入一个命名空间中(译注:你可以认为命名空间就是一个对象):

// @filename: app.ts
import * as math from "./maths.js";
console.log(math.pi);
const positivePhi = math.absolute(math.phi);

你还可以用 import "./file" 在引入一个文件时声明任何变量:

// @filename: app.ts
import "./maths.js";
console.log("3.14");

上面代码的 import 没有导入任何变量,但是,maths.ts 的代码会被执行,代码执行可能会对其他对象造成一些副作用。

TS 独有的 ESM 语法

TS 中,ESM 语法不仅可以导出/导出变量,还可以导入/导出类型:

// @filename: animal.ts
// 导出类型 Cat
export type Cat = { breed: string; yearOfBirth: number };
// 导出接口 Dog
export interface Dog {
 breeds: string[];
 yearOfBirth: number;
}
// @filename: app.ts
// 导出类型 Cat 和 Dog
import { Cat, Dog } from "./animal.js";
type Animals = Cat | Dog;

TS 用 import 导入类型时,有两个额外的概念:

import type

这种语法只能用于导入类型,如果用来导入变量,就会报错。

Inline type imports

TypeScript 4.5 允许你把 type 写到 { } 里面:

// @filename: app.ts
import { createCatName, type Cat, type Dog } from "./animal.js";
export type Animals = Cat | Dog;
const name = createCatName();

这两个语法允许 Babel、swc 或 esbuild 等转译器知道哪些导入可以被安全地擦除。

拥有 CommonJS 行为的 ESM 语法

TS 还有一种跟 require 一起用的 import 语法。它的作用与 const x = require('xxx') 基本一致。而且 import x = require('xxx') 要求被 require 的模块必须用 export = x 来导出变量。

// ZipCodeValidator.ts
let numberRegexp = /^[0-9]+$/;
class ZipCodeValidator {
 isAcceptable(s: string) {
 return s.length === 5 && numberRegexp.test(s);
 }
}
export = ZipCodeValidator;
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"}`
 );
});

CommonJS 语法

npm 上大部分包都是用了 CommonJS 模块语法,因此就算你平时只用 ESM 语法,你也有必要了解 CommonJS 语法的工作原理,以帮助你更好地 debug。

导入与导出

module.exports 的所有属性都会被导出:

function absolute(num: number) {
 if (num < 0) return num * -1;
 return num;
}
module.exports = {
 pi: 3.14,
 squareTwo: 1.41,
 phi: 1.61,
 absolute,
};

然后通过 require 导入:

const maths = require("maths");
maths.pi;

或者你也可以配合解构赋值语法:

const { squareTwo } = require("maths");
squareTwo;

CommonJS 与 ESM 交互

由于 ESM 有 export defaultimport * as name 语法,而 CommonJS 没有,所以 TS 提供了一个编译器选项 esModuleInterop 来减少两套模块语法之前的摩擦。

默认情况下,TS 把 CommonJS/AMD/UMD 模块看作是与 ES 模块类似的东西,因此就会默认有两个错误的假设:

  1. import * as moment from "moment" 和 const moment = require("moment") 表现一致。
  2. import moment from "moment" 和 const moment = require("moment").default 表现一致。

这里面有两个错误:

  1. import * as moment from "moment" 中的 moment 只能是对象,但是 const moment = require("moment") 中的 moment 可以是函数。moment 如果是函数,就不符合 ESM 的要求。
  2. 大多数使用 CommonJS/AMD/UMD 模块语法的库虽然符合 ESM 规范, 但实现其实并不像 TypeScript 的实现那样严格。

Turning on esModuleInterop will fix both of these problems in the code transpiled by TypeScript. The first changes the behavior in the compiler, the second is fixed by two new helper functions which provide a shim to ensure compatibility in the emitted JavaScript:

esModuleInterop 设置为 true,可以修复 TS 编译出的 JS 代码中的这两个问题:第一个问题通过改变编译器的行为来解决,第二个问题通过新增两个辅助函数来解决。以下面的代码为例:

import * as fs from "fs";
import _ from "lodash";
fs.readFileSync("file.txt", "utf8");
_.chunk(["a", "b", "c", "d"], 2);

esModuleInteropfalse 时,生成的 JS 为(译注:为了让你更容易地理解代码,本人对代码进行了改写,想看原文请点这里。另外,你可以启用 importHelpers 来使产生的 JS 更加简洁):

"use strict";
exports.__esModule = true
const fs = require("fs");
const lodash_1 = require("lodash");
fs.readFileSync("file.txt", "utf8");
// _ 变成了 lodash_1.default
lodash_1.default.chunk(["a", "b", "c", "d"], 2);

esModuleInteroptrue 时,生成的 JS 为:

"use strict";
var __importStar = function (mod) {
 var result = {};
 for (var k in mod) if (k !== "default") result[k] = mod[k];
 result.default = mod.default;
 return result;
};
var __importDefault = function (mod) {
 return mod.__esModule ? mod : { "default": mod };
};
exports.__esModule = true
// fs 会多出一个 default 属性
const fs = __importStar(require("fs"));
// lodash_1 会有一个 default 属性
const lodash_1 = __importDefault(require("lodash"));
fs.readFileSync("file.txt", "utf8");
lodash_1.default.chunk(["a", "b", "c", "d"], 2);

注意:import * as fs from "fs" 得到的 fs 无法访问其原型链上的属性或从父类(而不是类)得到的属性。如果你一定要访问这样的属性,那么你需要使用默认导入 import fs from "fs",或者将 esModuleInterop 设为 false

另外,如果你启用了 esModuleInterop,那么 allowSyntheticDefaultImports 就会自动被启用。

allowSyntheticDefaultImportstrue 时,如果被 import 的模块并没有默认导出,你依然可以使用如下代码来导入它:

import React from "react";

allowSyntheticDefaultImportsfalse 时,如下代码会报错:

// @filename: utilFunctions.js
const getStringLength = (str) => str.length;
// 注意,没有默认导出
module.exports = {
 getStringLength, 
};
// @filename: index.ts
import utils from "./utilFunctions";
const count = utils.getStringLength("Check JS");

此时你最好把 import utils from "./utilFunctions"; 写成 import * as utils from "./utilFunctions";

另外,有些转译器如 babel,会自动给 module.exports 添加一个 default 属性,效果跟下面的代码类似:

// @filename: utilFunctions.js
const getStringLength = (str) => str.length;
const allFunctions = {
 getStringLength,
};
module.exports = allFunctions;
module.exports.default = allFunctions;

TS 的模块解析选项

Module resolution is the process of taking a string from the import or require statement, and determining what file that string refers to.

TypeScript includes two resolution strategies: Classic and Node. Classic, the default when the compiler option module is not commonjs, is included for backwards compatibility. The Node strategy replicates how Node.js works in CommonJS mode, with additional checks for .ts and .d.ts.

There are many TSConfig flags which influence the module strategy within TypeScript: moduleResolutionbaseUrlpathsrootDirs.

For the full details on how these strategies work, you can consult the Module Resolution.

模块解析是指从 importrequire 语句中获取依赖的名称或路径,然后找到其所指的文件的过程。

TypeScript 有两种解析策略:经典解析和 Node 解析。当 compilerOptions 里的 module 不是 commonjs 是,就采用经典策略。否则就采用 Node策略,该策略模仿 Node.js 对 CommonJS 模块的处理方式,.ts 和.d.ts 文件进行额外的检查。

在 TypeScript 中,有许多 TSConfig 选项影响模块策略, 如 moduleResolution、baseUrl、paths、rootDirs 等。

你可以在「模块解析」章节了解这两种策略的全部细节。

TS 的模块输出选项

有两个选项会影响 TS 代码输出的 JS 代码。

  1. target,它决定了哪些JS功能被降级,哪些被保留。
  2. module,它决定了哪些代码用于模块之间的相互作用。

For example, here is a TypeScript file using ES Modules syntax, showcasing a few different options for module:

以下面这个使用了 ESM 语法的 TS 文件为例,你可以通过它理解不同 module 值产生的效果:

import { valueOfPi } from "./constants.js";
export const twoPi = valueOfPi * 2;

moduleES2020 时,输出的 JS 代码如下(跟原文一模一样):

import { valueOfPi } from "./constants.js";
export const twoPi = valueOfPi * 2;

moduleCommonJS 时,输出的 JS 代码为:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.twoPi = void 0;
const constants_js_1 = require("./constants.js");
exports.twoPi = constants_js_1.valueOfPi * 2;

moduleUMD 时,输出的 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", "./constants.js"], factory);
 }
})(function (require, exports) {
 "use strict";
 Object.defineProperty(exports, "__esModule", { value: true });
 exports.twoPi = void 0;
 const constants_js_1 = require("./constants.js");
 exports.twoPi = constants_js_1.valueOfPi * 2;
});

你可以在 TSConfig Reference for module 章节查看所有 module 值和对应的输出。

TS 命名空间

在 ESM 之前,TypeScript 用 namespaces 来表示模块,namespaces 在 DefinitelyTyped 中被广泛使用。虽然 namespaces 还没有被废弃,但其大部分功能都被 ESM 所代替,所以我们建议你使用 ESM 而不是 namespaces,你可以在 namespaces 章节查看更详细的信息。

作者:方应杭

%s 个评论

要回复文章请先登录注册