第11章 模块系统与声明文件

第 11 章 模块系统与声明文件

如果说 TypeScript 是一座繁华的城市,模块系统就是城市的交通网络——没有它,所有的代码都挤在同一条马路上,乱成一锅粥。模块系统让你把代码拆分成独立的小区,每个小区有自己的出入口(导入/导出),互不干扰,各司其职。

11.1 ES 模块导出

11.1.1 声明导出、命名导出、默认导出、导出 all 与重导出

ES 模块(ESM)是现代 JavaScript/TypeScript 的标准模块系统。它有两种导出方式:命名导出默认导出

命名导出:每个导出有名字,导入时必须用这个名字

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// math.ts
export const PI = 3.14159;
export function add(a: number, b: number): number {
    return a + b;
}
export class Calculator {
    multiply(a: number, b: number): number { return a * b; }
}

// 导入时必须用同样的名字
import { PI, add, Calculator } from "./math";
console.log(PI); // 3.14159

声明导出:直接在声明前面加 export

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// shapes.ts
export interface Point {
    x: number;
    y: number;
}

export type Shape = "circle" | "square" | "triangle";

export enum Direction {
    Up, Down, Left, Right,
}

默认导出:每个模块只能有一个默认导出,导入时名字可以随便起

1
2
3
4
5
6
7
8
// greeter.ts
export default function greet(name: string): string {
    return `Hello, ${name}!`;
}

// 导入时可以随便起名字
import greet from "./greeter";
console.log(greet("World")); // Hello, World!

混合导出:一个模块可以同时有命名导出和默认导出

1
2
3
4
5
6
7
// utils.ts
export default class Utils { /* ... */ }
export const VERSION = "1.0.0";
export function helper() { /* ... */ }

// 导入
import Utils, { VERSION, helper } from "./utils";

重导出(Re-export):把其他模块的导出再导出去

1
2
3
4
5
6
7
8
9
// index.ts
// 把 math 模块的所有导出再导出
export * from "./math";

// 只导出部分
export { add, Calculator } from "./math";

// 重导出并重命名
export { add as addNumbers } from "./math";

11.2 ES 模块导入

11.2.1 命名导入、默认导入、复合导入与整体导入

命名导入:精确导入想要的命名导出

1
2
import { PI, add } from "./math";
import { add as sum } from "./math"; // 重命名导入

默认导入:导入默认导出

1
import greet from "./greeter";

复合导入:同时导入默认导出和命名导出

1
import greet, { PI, add } from "./greeter";

整体导入:把整个模块导入为一个对象,所有命名导出变成对象的属性

1
2
3
4
import * as math from "./math";
console.log(math.PI);           // 3.14159
console.log(math.add(1, 2));    // 3
console.log(math.Calculator);    // [class Calculator]

11.2.2 import defer(TS 5.9 新增)

11.2.2.1 语法:import defer * as names from 'module'

import defer 是 TypeScript 5.9 引入的新语法,允许你延迟执行模块的代码。

1
2
3
4
5
6
7
8
9
// 定义一个延迟加载的模块
import defer * as analytics from "./analytics";

// 模块代码不会立即执行
console.log("页面加载完成");
// analytics 的代码在这里还没有执行

// 只有当你真正使用 analytics 时,模块才会执行
analytics.trackPageView("/home");

11.2.2.2 语义:模块在当前作用域中延迟执行,仅当真正访问时才执行

import defer 的核心语义是"延迟执行":

  • 模块的顶层代码(变量声明等)在导入时不执行
  • 模块的顶层代码在第一次访问模块的任何导出时才执行
  • 执行后,模块状态被缓存,后续访问直接使用缓存结果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// analytics.ts
console.log("analytics 模块开始加载..."); // 这行代码在第一次访问 analytics 之前不会执行

export function trackPageView(page: string) {
    console.log(`跟踪页面: ${page}`);
}

export const config = { apiKey: "xxx" };

// main.ts
import defer * as analytics from "./analytics";
console.log("模块加载完毕");

analytics.trackPageView("/home"); // 第一次访问,触发模块执行
// 输出: analytics 模块开始加载...
// 输出: 跟踪页面: /home

analytics.trackPageView("/about"); // 第二次访问,使用缓存,不重复执行模块代码

11.2.2.3 设计动机:大型应用的按序加载管理,减少初始包体积;类似 HTML 的 <script defer>

这个语法的设计动机和 HTML 的 <script defer> 完全一样:

  • 不使用 defer:模块立即执行,阻塞后续代码
  • 使用 defer:模块延迟执行,不阻塞后续代码

典型应用场景:

  • 日志/监控模块:不影响主业务流程
  • 大型库:延迟加载减少初始 bundle 体积
  • 按需初始化的配置:如 i18n 配置、主题配置

11.2.3 Subpath Imports with #/(TS 6.0 新增)

11.2.3.1 Node.js 20+ 支持 #/ 开头的 subpath import

#/subpath 是 Node.js 20+ 引入的一种特殊导入路径,用于简化内部模块导入

11.2.3.2 语法:import { something } from '#/utils'

1
2
3
4
// 从项目根目录的 src/utils/index.ts 导入
import { formatDate } from "#/utils";
// 相当于
import { formatDate } from "./src/utils/index.ts"; // 不用再写相对路径

11.2.3.3 配置:package.json 的 imports 字段中使用 "#": "./dist/index.js""#/*": "./dist/*"

package.json 中配置路径映射:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
    "name": "my-package",
    "exports": {
        ".": "./dist/index.js"
    },
    "imports": {
        "#utils": "./dist/utils/index.js",
        "#utils/*": "./dist/utils/*.js",
        "#config": "./dist/config/index.js",
        "#config/*": "./dist/config/*.js"
    }
}

11.2.3.4 支持范围:--moduleResolution 为 node20 / nodenext / bundler 时启用

这个功能需要正确的 moduleResolution 配置:

1
2
3
4
5
{
    "compilerOptions": {
        "moduleResolution": "node20" // 或 "nodenext" 或 "bundler"
    }
}

11.2.4 类型导入与导出

TypeScript 扩展了 ES 模块,支持专门导入/导出类型

11.2.4.1 import type:import type { SomeType } from 'module'

1
2
3
4
import type { User, Admin } from "./types";

// 编译后,这行 import 会被完全删除
// 因为类型信息在运行时不存在

11.2.4.2 export type:export type { SomeType }

1
2
3
4
// types.ts
export type User = { id: number; name: string };
export interface Admin extends User { permissions: string[]; }
export { type User, type Admin }; // 只导出类型

11.2.4.3 为什么需要 import type:纯类型导入不产生运行时代码;解决循环导入问题

import type 的价值在于编译时完全消除——纯类型的导入在编译成 JavaScript 后会消失,不会产生任何运行时代码。

1
2
3
4
5
6
7
// user.ts
import type { Config } from "./config"; // 编译后消失
import { fetchUser } from "./api";       // 编译后保留

export function getUser(id: number) {
    // ...
}

另外,import type 可以帮助解决某些循环导入问题——因为纯类型导入在编译后消失,不会在运行时造成问题。

11.2.5 import() 断言废弃(TS 6.0)

11.2.5.1 旧语法:import(obj, { assert: { type: 'json' } }) → 已废弃

旧版的动态 import 使用 assert 语法:

1
2
3
const data = await import("./config.json", {
    assert: { type: "json" },
});

11.2.5.2 新语法:import(obj, { with: { type: 'json' } })

TypeScript 6.0 将 assert 替换为 with

1
2
3
const data = await import("./config.json", {
    with: { type: "json" },
});

注意:这是运行时行为的变化,不是 TypeScript 独有的。Node.js 和浏览器也在逐步切换到 with 语法。


11.3 模块的运行时行为

11.3.1 ES Module vs CommonJS 运行时差异

JavaScript 有两套模块系统:ES Module(ESM)CommonJS(CJS)。它们在运行时行为上有显著差异。

11.3.1.1 ESM:静态结构(import 在模块顶层,不能在条件语句中)、异步加载、import 绑定只读

ES Module 的核心特征是静态结构

1
2
3
4
5
6
// ESM - 静态导入,必须在模块顶层
import { foo } from "./module"; // 不能在 if 语句里
import.meta.url; // 访问模块元信息

export { foo };
export default bar;

11.3.1.2 CJS:动态结构(require 可在任意位置)、同步加载、module.exports 可变

CommonJS 的核心特征是动态结构

1
2
3
4
5
// CJS - 动态导入,可以在任何地方
if (someCondition) {
    const foo = require("./module");
}
module.exports = { foo };

11.3.1.3 混用限制:ESM 文件中不能使用 require;CJS 文件中不能使用动态 import(除非在 async 函数内)

1
2
3
4
5
// ESM 文件中,动态 import() 是允许的(它是异步的)
const mod = await import("./module");

// 但 CJS 中,动态 import() 在顶层不能使用(需要 async 函数包装)
// const mod = await import("./module"); // 在 CJS 顶层会报错

11.3.2 esModuleInterop 配置的作用

esModuleInterop 是 TypeScript 编译选项中最重要的选项之一,它解决的是 ESM 和 CJS 之间的互操作性问题

11.3.2.1 让 import 'module' 在 default import 缺失时自动桥接(即使模块只有 CommonJS 导出)

在没有 esModuleInterop 的情况下,导入 CJS 模块时会有奇怪的行为:

1
2
3
// 没有 esModuleInterop 时
import fs from "fs"; // fs 是 CJS 模块,只有 named exports,没有 default export
// TypeScript 报错或产生 undefined

有了 esModuleInterop: true

1
2
3
4
5
6
// tsconfig.json
{
    "compilerOptions": {
        "esModuleInterop": true
    }
}
1
2
import fs from "fs"; // OK!现在 TypeScript 会自动处理 CJS 模块
fs.readFileSync("./file.txt", "utf-8");

11.3.2.2 自动在输出的 bundle 中添加 ESM/CommonJS 兼容层(如 __importStar__importDefault

esModuleInterop 会在编译输出中注入辅助函数:

1
2
3
4
5
// 编译前
import fs from "fs";

// 编译后(开启 esModuleInterop)
import __import fs from "fs";

11.3.3 Combining –moduleResolution bundler with –module commonjs(TS 6.0 新增)

11.3.3.1 以前:--moduleResolution bundler 只能与 –module esnext 或 –module preserve 组合

在 TS 6.0 之前,--moduleResolution bundler 是一个"偏食"的选项——它只和 ESM 风格的 --module 配置搭配。

1
2
3
4
5
6
7
// 旧配置(报错)
{
    "compilerOptions": {
        "moduleResolution": "bundler",
        "module": "commonjs" // 不兼容!
    }
}

11.3.3.2 现在:允许与 –module commonjs 组合,为迁移提供更灵活的路径

TS 6.0 解除了这个限制:

1
2
3
4
5
6
7
// 新配置(OK)
{
    "compilerOptions": {
        "moduleResolution": "bundler",
        "module": "commonjs"
    }
}

这为那些想用 bundler 的路径解析策略,但又需要输出 CommonJS 的项目提供了更大的灵活性。


11.4 声明文件(.d.ts)

11.4.1 声明文件的作用

.d.ts 文件是 TypeScript 的"类型声明文件"——它只描述类型,不包含任何运行时代码。

11.4.1.1 为 JavaScript 代码补上类型信息,不包含实现

当你有一个纯 JavaScript 库时,可以用 .d.ts 文件给它加上类型信息:

1
2
3
4
5
// my-lib.d.ts(类型声明文件)
declare module "my-lib" {
    export function greet(name: string): string;
    export const VERSION: string;
}

11.4.1.2 为什么需要 .d.ts:JavaScript 代码没有类型信息,TypeScript 需要类型才能检查

JavaScript 是动态类型语言,没有类型信息。TypeScript 需要类型信息才能做类型检查。.d.ts 文件就是 TypeScript 的"眼睛"——让它在没有类型的 JavaScript 代码中也能做类型检查。

11.4.1.3 .d.ts 只包含类型声明,不包含实现代码;编译时使用,运行时不加载

1
2
3
4
5
6
// 声明文件
declare function add(a: number, b: number): number;
declare const PI: number;
declare class Calculator { /* ... */ }

// 这些声明在编译后完全消失,不产生任何 JavaScript 代码

11.4.2 全局声明:declare vardeclare functiondeclare namespace

.d.ts 文件中,可以使用 declare 关键字来声明全局变量、函数和命名空间:

1
2
3
4
5
6
7
// global.d.ts
declare var VERSION: string;
declare function log(message: string): void;
declare namespace MathUtils {
    export function add(a: number, b: number): number;
    export function multiply(a: number, b: number): number;
}

11.4.3 模块声明:declare module 'module-name'

为某个 npm 包或本地模块声明类型:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// my-plugin.d.ts
declare module "my-plugin" {
    interface PluginOptions {
        debug?: boolean;
        timeout?: number;
    }

    export function init(options?: PluginOptions): void;
    export const version: string;
}

11.5 @types 与 DefinitelyTyped

11.5.1 @types/* 包的安装与使用

TypeScript 社区为几乎所有流行的 npm 包维护了类型声明——这就是 @types/* 包。

1
2
3
4
5
6
7
8
# 安装 React 的类型声明
npm install --save-dev @types/react

# 安装 Lodash 的类型声明
npm install --save-dev @types/lodash

# 安装 Node.js 的类型声明(用于 Node.js 环境)
npm install --save-dev @types/node

安装后,TypeScript 会自动找到并使用这些类型声明,不需要任何额外配置。

11.5.2 为什么有 @types

11.5.2.1 JavaScript 生态在 TypeScript 出现前就存在大量库;DefinitelyTyped 社区为每个库编写并维护类型声明文件

JavaScript 生态有数十万个 npm 包,它们都是在 TypeScript 诞生前写的,没有类型信息。DefinitelyTyped(DefinitelyTyped.org)是一个社区项目,为这些库补上类型声明。

11.5.3 为无类型的 npm 包编写 declare module

如果你用的 npm 包没有类型声明,可以自己写一个声明文件:

1
2
3
4
5
6
7
8
// 自定义声明文件 src/custom.d.ts
declare module "some-untyped-package" {
    export function doSomething(input: string): Promise<{ result: string }>;
    export const config: { debug: boolean };
}

// 使用
import { doSomething, config } from "some-untyped-package";

11.6 声明合并

TypeScript 允许同名的 interface、namespace、enum 声明自动合并——这是一个独特的特性,在其他静态类型语言中很少见。

11.6.1 同名 interface 合并

当两个同名的 interface 相遇时,它们的成员会自动合并:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
interface Animal {
    name: string;
}

interface Animal {
    age: number;
}

// 相当于
interface Animal {
    name: string;
    age: number;
}

const animal: Animal = {
    name: "小狗",
    age: 3,
};

11.6.1.1 非函数成员:直接合并,类型不一致时报错

1
2
3
4
5
6
7
interface A {
    id: number;
}

interface A {
    id: string; // 报错!类型不一致,无法合并
}

11.6.1.2 函数成员:多个签名作为重载列表,依次排列(更具体的签名在前)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
interface Greeter {
    greet(name: string): string;
}

interface Greeter {
    greet(name: string, age: number): string;
}

// 相当于
interface Greeter {
    greet(name: string, age?: number): string;
    // 重载列表:先匹配无 age 的,再匹配有 age 的
}

const greeter: Greeter = {
    greet(name: string, age?: number) {
        if (age !== undefined) {
            return `你好 ${name},你 ${age} 岁了`;
        }
        return `你好 ${name}`;
    },
};

console.log(greeter.greet("小明"));            // 你好 小明
console.log(greeter.greet("小红", 18));        // 你好 小红,你 18 岁了

11.6.2 同名 namespace 合并

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace Config {
    export const name = "App";
    export function init() { console.log("Config 初始化了"); }
}

namespace Config {
    export const version = "1.0.0";
    export function start() { console.log("Config 启动了"); }
}

// 相当于
namespace Config {
    export const name = "App";
    export const version = "1.0.0";
    export function init() { console.log("Config 初始化了"); }
    export function start() { console.log("Config 启动了"); }
}

Config.init();
// Config 初始化了
Config.start();
// Config 启动了

11.6.2.1 namespace 内的导出成员会合并到同一命名空间

11.6.3 命名空间与类的合并

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
class User {
    name: string = "张三";
}

// 在 User 类的命名空间中扩展静态属性
namespace User {
    export let count: number = 0;
    export function createGuest() {
        count++;
        return new User();
    }
}

// User 类本身是实例类型
// User 命名空间提供静态成员
console.log(User.count);      // 0
const guest = User.createGuest();
console.log(User.count);      // 1

11.6.3.1 允许在 namespace 内扩展 class 的静态属性(在 .d.ts 中用于描述全局单例对象的扩展)

11.6.4 同名 type 不合并(别名冲突直接报错)

1
2
type Id = number;
type Id = string; // 报错!Cannot redeclare type alias 'Id'

type 别名在 TypeScript 中不会合并——这是和 interface 的最大区别。


本章小结

本章系统地介绍了 TypeScript 的模块系统和声明文件。

ES 模块

ES Module 是现代 JavaScript 的标准模块系统,核心特征是静态结构(import/export 在顶层,不能条件执行)。

  • 命名导出/导入export { foo } / import { foo }
  • 默认导出/导入export default bar / import bar
  • 整体导入import * as ns
  • 重导出export { foo } from "./module"

新特性

  • import defer(TS 5.9):延迟模块执行,类似 <script defer>
  • Subpath Imports #/(TS 6.0):简化内部模块导入路径
  • moduleResolution bundler + commonjs(TS 6.0):解除组合限制

类型导入

  • import type / export type:纯类型导入,编译后消除
  • import() 断言改用 with 语法

声明文件

.d.ts 文件为 JavaScript 库提供类型信息,declare 关键字用于声明全局变量、函数、命名空间、模块。社区维护的 @types/* 包为绝大多数 npm 库提供开箱即用的类型支持。

声明合并

同名的 interfacenamespace 会自动合并(函数成员变成重载列表),但 type 别名不会合并。命名空间可以扩展同名类的静态成员——这是 .d.ts 文件中常见的模式。

模块系统是 TypeScript 项目的骨架。一个好的模块设计应该:高内聚(模块内部紧密相关)、低耦合(模块之间尽量少的依赖)、接口清晰(导出明确,隐藏内部细节)。