模块与声明文件

让类型在文件之间、项目之间自由传递

🎯 学习目标

学完本章,你将能够:

  • 能够使用 ESM 语法(export/import)组织模块代码
  • 能够编写 .d.ts 声明文件为 JS 库提供类型支持
  • 能够理解模块解析策略(Node、Bundler)与路径别名配置
  • 能够处理 CJS 与 ESM 互操作及默认导入陷阱
  • 能够运用 Barrel 模式与包导出配置优化模块结构

先看一个问题

你的 User 接口在 a.ts 里定义了,b.ts 也要用——怎么办?用了第三方库没有类型——又怎么办?

// a.ts —— 定义了 User 接口
interface User {
    id: string;
    name: string;
}

// b.ts —— 想用 User,但 TS 说"找不到 User"
function greet(user: User) { // 报错!
    console.log(user.name);
}
// 用了 lodash,但 TS 提示 any
import _ from "lodash";
_.chunk([1, 2, 3, 4], 2); // 没有类型提示

第一个问题的答案是模块导入/导出,第二个问题的答案是声明文件。这两个机制共同构成了 TypeScript 的"类型流通系统"。

Part 1: 类型导入

用 import/export 在文件之间传递类型

开篇的问题直击痛点——User 接口在 a.ts 里定义了,b.ts 想用怎么办?答案就是 import 和 export。但 TypeScript 还多了一个 type 修饰符,让你精确标记"这个导入是纯类型,编译后擦掉"。三种导入方式各有适用场景。

导出类型

从模块中暴露类型定义

// utils.ts
export interface User {
    id: string;
    name: string;
    email: string;
}

export type Status = "active" | "inactive";

export function getUser(id: string): User {
    return { id, name: "张三", email: "a@b.c" };
}

// 值和类型可以同名导出
export const DEFAULT_STATUS: Status = "active";

// 纯类型导出(type 修饰符)
export type { User, Status };
// 编译后会被擦除,不产生 JS 代码

导入类型

三种导入方式,推荐内联 type 标记

// 方式1:混合导入
import { User, Status, getUser } from "./utils";

// 方式2:仅导入类型
import type { User, Status } from "./utils";

// 方式3:内联 type 标记(推荐)
import { type User, type Status, getUser } from "./utils";
//           ^^^^                 ^^^^
// 标记这些是纯类型导入,编译后被擦除
// 重命名导入/导出
import { User as UserType } from "./utils";
export { getUser as fetchUser } from "./utils";

// 默认导出
export default class UserService {
    constructor(private users: User[] = []) {}
}
import UserService from "./utils";

推荐使用 import { type X } 内联标记,混合导入值和类型时最清晰。

Part 2: 全局声明

declare 告诉 TS:"这个东西在运行时存在,请相信我"

有些变量是运行时注入的——比如 __DEV__、process.env、CDN 加载的全局函数。TypeScript 不认识它们,会报"找不到"的错误。declare 关键字就是来救场的:它告诉编译器"别检查了,运行时一定有"。还可以给没有类型的第三方库补充声明。

declare 关键字

为运行时已存在的全局变量声明类型

// 声明全局变量
declare const API_URL: string;
declare const __DEV__: boolean;
declare const process: { env: Record<string, string> };

// 使用
if (__DEV__) {
    console.log("开发模式");
}

// 声明全局函数
declare function ga(command: string, ...args: unknown[]): void;
ga("send", "pageview");
// 声明全局模块——为无类型的库补充类型
declare module "some-untyped-lib" {
    export function doSomething(input: string): number;
    export const version: string;
}

// 使用——就像有类型的模块一样
import { doSomething } from "some-untyped-lib";
doSomething("hello"); // 有完整的类型提示

Part 3: .d.ts 文件

纯类型声明文件,不包含实现,编译后不输出 JS

declare 可以写在任意 .ts 文件里,但更好的做法是统一放到 .d.ts 文件中。这种文件只放类型声明,不放实现,编译后完全消失。扩展 Express 的 Request、声明环境变量类型、声明图片和 CSS 模块——都是 .d.ts 的典型用途。

编写 .d.ts

扩展第三方模块、声明环境类型

// types/custom.d.ts

// 扩展 express 的 Request 类型
declare module "express" {
    interface Request {
        userId?: string;
        sessionId?: string;
        user?: {
            id: string;
            name: string;
            role: "admin" | "user";
        };
    }
}
// 声明 .env 文件的类型
declare namespace NodeJS {
    interface ProcessEnv {
        NODE_ENV: "development" | "production" | "test";
        PORT?: string;
        DATABASE_URL: string;
        JWT_SECRET: string;
    }
}

// 使用时有完整的类型提示
process.env.DATABASE_URL; // string
process.env.JWT_SECRET;   // string
// process.env.UNKNOWN;   // 报错!不在声明中

静态资源声明

让 TS 识别图片、CSS 等非 JS 导入

// types/assets.d.ts

// 声明图片模块
declare module "*.png" {
    const value: string;
    export default value;
}
declare module "*.svg" {
    const value: string;
    export default value;
}

// 声明 CSS 模块
declare module "*.css" {
    const classes: Record<string, string>;
    export default classes;
}

// 使用
import logo from "./logo.png";     // string(图片 URL)
import styles from "./button.css"; // Record<string, string>

Part 4: 补充类型

三种方式为第三方库获取类型,优先级从高到低

装了一个 npm 包,导入后全是 any——这种体验太糟糕了。TypeScript 生态有三种方式解决:库自带类型、安装 @types 包、手写声明文件。优先用前两种,手写是最后手段。我们来看看每种方式怎么做。

三种方式

从最简单到最复杂

优先级方式说明
1(最推荐)自带类型的库package.json 有 "types" 字段
2安装 @types/xxxDefinitelyTyped 社区维护
3(最后手段)手写 .d.ts自己维护,可能与库不同步
# 方式2:安装 DefinitelyTyped 类型包
$ npm install -D @types/express
$ npm install -D @types/node
$ npm install -D @types/lodash
# 安装后自动生效

手写声明示例

为无类型库补充完整的接口

// src/types/tiny-cache.d.ts
declare module "tiny-cache" {
    interface Options {
        ttl?: number;      // 过期时间(毫秒)
        maxSize?: number;  // 最大缓存条目数
    }

    class Cache {
        constructor(options?: Options);
        get<T>(key: string): T | undefined;
        set<T>(key: string, value: T, ttl?: number): void;
        delete(key: string): boolean;
        clear(): void;
        has(key: string): boolean;
        get size(): number;
    }

    export = Cache;
}

// 使用
import Cache from "tiny-cache";
const cache = new Cache({ ttl: 60000 });
cache.set("user:1", { name: "张三" });
const user = cache.get<{ name: string }>("user:1");
注意:手写的声明文件可能与库的实际 API 不一致,升级库时需要同步更新声明。

Part 5: 全局扩展

扩展现有的全局接口,如 Window、Array 等

有时候你需要给 Window 加自定义属性,或者给 Array 加个 unique 方法。利用声明合并和 declare global,就能安全地扩展内置接口。但有一个关键陷阱:你的 .d.ts 文件到底是"模块"还是"脚本"?这决定了扩展全局类型的方式。

扩展全局接口

给 Window、String 等内置接口添加自定义方法

// 扩展 Window 接口
declare global {
    interface Window {
        __APP_VERSION__: string;
        analytics: {
            track(event: string, data?: Record<string, unknown>): void;
            identify(userId: string): void;
        };
    }
}

// 使用
window.__APP_VERSION__; // string
window.analytics.track("page_view", { page: "/home" });
// 扩展 Array 接口
declare global {
    interface Array<T> {
        /** 返回去重后的新数组 */
        unique(): T[];
    }
}

模块 vs 脚本

一个关键区别决定作用域

特征模块模式脚本模式
判断标准有 import 或 export没有任何导入导出
作用域文件级别(独立作用域)全局作用域
扩展全局类型需要 declare global {}直接声明即可
// 如果需要扩展全局类型,文件至少有一个 export {} 让它成为模块
export {}; // 这一行让文件变成模块

declare global {
    interface Window {
        myCustomProp: string;
    }
}

三斜线指令

现代项目中已很少使用

// 三斜线指令——在 .d.ts 中引用其他声明
/// <reference types="node" />         // 引入 Node.js 类型
/// <reference path="./types.d.ts" />   // 引入相对路径的类型文件

现代 TS 项目中三斜线指令已很少使用,大多数场景通过 tsconfig.json 的 types 和 include 配置即可。

路径别名

告别 "../../../" 的地狱嵌套

// tsconfig.json
{
    "compilerOptions": {
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"],
            "@components/*": ["src/components/*"],
            "@utils/*": ["src/utils/*"]
        }
    }
}
// 没有别名——痛苦
import { User } from "../../../types/user";
import { formatDate } from "../../utils/date";

// 使用别名——清爽
import { User } from "@/types/user";
import { formatDate } from "@/utils/date";
# 运行时也需要处理(Node.js 不认识 @/)
# 方案1:tsconfig-paths
$ npx ts-node -r tsconfig-paths/register src/index.ts

# 方案2:构建时替换(esbuild、tsc-alias)
# 方案3:package.json 的 imports(Node 12.20+)

路径别名在开发时很爽,但运行时需要额外处理。建议只定义 1-2 个顶级别名(如 @/*),别搞太多层级。

解析流程

import 一行代码背后发生了什么?

图:模块解析流程

理解这个流程,遇到"找不到模块"时就能按图索骥:先查 paths,再查 @types,最后查文件是否存在。

Barrel 模式

index.ts 统一导出——方便但要注意 tree-shaking

// src/components/Button.ts
export class Button { /* ... */ }

// src/components/Input.ts
export class Input { /* ... */ }

// src/components/Modal.ts
export class Modal { /* ... */ }

// src/components/index.ts(barrel 文件)
export { Button } from "./Button";
export { Input } from "./Input";
export { Modal } from "./Modal";
// 使用方——一行搞定
import { Button, Input } from "@/components";
// 不用写:
// import { Button } from "@/components/Button";
// import { Input } from "@/components/Input";
注意 tree-shaking:某些打包器(尤其是 webpack)可能无法完全 tree-shake barrel 文件,导致打包体积增大。如果对包体积敏感,考虑直接导入具体文件。

Part 6: ESM 与 CJS

两大模块体系的差异与互操作,是 Node.js 项目中的高频痛点

新项目用 ESM,老代码用 CJS,npm 生态两套体系并存。import 和 require 能不能混用?为什么有时候默认导入会报错?esModuleInterop 到底该不该开?这一节把这些问题一次性说清楚,再补充模块解析策略和循环依赖的处理方案。

CJS 与 ESM 对比

两种模块系统的核心差异

特征CommonJS (CJS)ES Modules (ESM)
语法require() / module.exportsimport / export
加载时机运行时同步加载编译时静态分析
值的性质值的拷贝(可修改)值的绑定(只读引用)
Tree-shaking不支持支持
顶层 await不支持支持
文件扩展名.cjs.mjs
// CJS 写法
const fs = require("fs");
module.exports = { read: fs.readFileSync };

// ESM 写法
import fs from "fs";
export const read = fs.readFileSync;

新项目优先使用 ESM。CJS 仍然广泛存在于 npm 生态中,所以"互操作"是日常必修课。

图:ESM 与 CJS 的值绑定差异

默认导入陷阱

CJS 模块的 default 导入与 esModuleInterop

// CJS 库的实际导出:
// module.exports = function() { ... }

// 没有 esModuleInterop 时——必须用 * as
import * as express from "express";   // OK
import express from "express";        // 报错!

// 开启 esModuleInterop 后——可以直接默认导入
import express from "express";        // OK!TS 自动合成 default
express(); // 正常调用
// tsconfig.json
{
    "compilerOptions": {
        "esModuleInterop": true,           // 推荐开启
        "allowSyntheticDefaultImports": true // 通常随 esModuleInterop 自动开启
    }
}
常见坑:esModuleInterop 同时影响输入和输出。如果你是库作者,开启后生成的代码会使用 __importDefault 辅助函数,可能与某些运行环境不兼容。库项目建议谨慎评估。

扩展名区分

.mts / .mjs / .cts / .cjs 显式声明模块类型

扩展名TS 源码编译输出声明文件
ESM.mts.mjs.d.mts
CJS.cts.cjs.d.cts
由 package.json 决定.ts.js.d.ts
// math.mts —— 明确为 ES Module
export function add(a: number, b: number): number {
    return a + b;
}
// 编译输出: math.mjs,声明文件: math.d.mts

// legacy.cts —— 明确为 CommonJS
export function legacyHelper(): void { }
// 编译输出: legacy.cjs,声明文件: math.d.cts

在混合 CJS/ESM 的项目中,使用显式扩展名可以避免歧义。.ts 的模块类型由最近的 package.json 中的 "type" 字段决定。

模块解析策略

moduleResolution 的三种模式对比

特性node (classic)node16 / nodenextbundler
适用场景旧项目兼容Node.js 原生 ESMVite/webpack 等打包工具
相对导入写扩展名不需要必须写 .js不需要
package.json exports不识别完整支持完整支持
条件导入不支持支持支持
推荐程度不推荐新项目使用Node.js 项目首选前端项目首选
// node16 模式下——必须写 .js 扩展名(即使源文件是 .ts!)
import { User } from "./user.js";      // 正确
import { User } from "./user";         // 报错!
import { User } from "./user.ts";      // 报错!不能写 .ts

// bundler 模式下——省略扩展名没问题
import { User } from "./user";         // OK
最大坑:node16 模式要求相对路径导入写 .js 而不是 .ts。这是因为 TS 编译后输出的是 .js 文件,导入路径不会重写。新手几乎都会踩这个坑。

包导出配置

package.json exports 与条件导出

// package.json
{
    "name": "my-lib",
    "type": "module",
    "main": "./dist/index.cjs",
    "module": "./dist/index.mjs",
    "types": "./dist/index.d.ts",
    "exports": {
        ".": {
            "import": {
                "types": "./dist/index.d.mts",
                "default": "./dist/index.mjs"
            },
            "require": {
                "types": "./dist/index.d.cts",
                "default": "./dist/index.cjs"
            }
        },
        "./utils": {
            "import": "./dist/utils.mjs",
            "require": "./dist/utils.cjs"
        }
    }
}
// 消费方使用——自动匹配正确的格式
import { hello } from "my-lib";         // ESM → index.mjs
import { helper } from "my-lib/utils";  // ESM → utils.mjs

const { hello } = require("my-lib");    // CJS → index.cjs

exports 字段让同一个包同时为 ESM 和 CJS 消费者提供正确的入口。类型声明的匹配顺序:types 条件 → 文件扩展名(.d.mts / .d.cts)→ .d.ts。

多版本类型

typesVersions 为不同 TS 版本提供类型

// package.json
{
    "types": "./dist/index.d.ts",
    "typesVersions": {
        ">=5.0": {
            "*": ["./dist/index.d.ts"]
        },
        ">=4.7": {
            "*": ["./dist/ts4.7/index.d.ts"]
        },
        "*": {
            "*": ["./dist/legacy/index.d.ts"]
        }
    }
}

利用 typesVersions,库作者可以为新版本 TS 提供更精确的类型(如 const 泛型参数),同时保持旧版本的兼容性。大多数项目不需要配置它,了解即可。

循环依赖

什么是循环依赖?为什么它会引发运行时崩溃?

// a.ts
import { b } from "./b";
export const a = 1;
console.log(b); // 可能是 undefined!

// b.ts
import { a } from "./a";
export const b = 2;
console.log(a); // 可能是 undefined!
图:循环依赖的加载流程
循环依赖的表现:运行时拿到 undefined,TypeScript 编译不报错但行为不可预测。这是 JS 模块系统中最隐蔽的 bug 来源之一。

打破循环

三种常用模式消除循环依赖

// 模式1:提取共享类型到第三个文件
// types.ts(无依赖,最推荐)
export interface User { id: string; name: string; }
export interface Order { id: string; userId: string; }

// 模式2:延迟导入(lazy import)
// 只在函数内部导入,而非文件顶层
export function processOrder() {
    const { getUser } = require("./user-service"); // 运行时才导入
    const user = getUser(order.userId);
}

// 模式3:依赖注入——通过参数传入,而非导入
export function createOrder(userService: UserService) {
    // 不 import UserService,由调用方传入
    const user = userService.getUser(userId);
}

模式1(提取共享类型)是最彻底的解决方案。模式2和3适合重构成本高的存量代码。可以用 madge 工具可视化检测循环依赖:npx madge --circular src/。

ESM 声明文件

.d.mts — TS 4.7+ 为 ES Module 提供的声明格式

// math.mts(源文件)
export function add(a: number, b: number): number {
    return a + b;
}
export const PI = 3.14;
// math.d.mts(自动生成的声明文件)
export declare function add(a: number, b: number): number;
export declare const PI: 3.14;
源文件编译输出声明文件模块类型
.mts.mjs.d.mtsES Module
.cts.cjs.d.ctsCommonJS
.ts.js.d.ts由 type 字段决定

TS 4.7 引入了 .d.mts 和 .d.cts,让声明文件的模块类型与源码严格对应。发布 npm 包时,确保声明文件的扩展名与编译输出匹配。

模块最佳实践

项目中的模块使用规范

// 1. 使用 import type 让 tree-shaking 更彻底
import { type User, getUser } from "./user";
//    ^^^^^^^^^^ 编译后擦除,打包工具可以安全移除 User 相关代码

// 2. 开启 isolatedModules(几乎所有项目都应开启)
// tsconfig.json: "isolatedModules": true
// 确保每个文件可以独立编译,兼容 esbuild / Babel / SWC

// 3. 善用 re-export 简化公开 API
// src/index.ts
export { UserService } from "./services/user";
export type { User, UserRole } from "./types/user";
export { formatDate, parseDate } from "./utils/date";
// 4. 避免深层嵌套的 barrel 文件
// 好的做法:只在顶层做一次 re-export
export { Button } from "./components/Button";
export { Input } from "./components/Input";

// 不好的做法:每层都有 index.ts 做 barrel
// components/ui/forms/inputs/index.ts
// components/ui/forms/index.ts
// components/ui/index.ts
// components/index.ts
// 四层 barrel = 四次模块解析 = 打包变慢
isolatedModules 为什么重要?如果你用了 const enum 或 namespace 合并,这些特性要求跨文件类型信息。开启 isolatedModules 后 TS 会禁止这些用法,确保项目兼容 esbuild 等单文件编译器。Vite 项目默认就依赖此模式。

Express 练习

练习

任务:为一个 Express 项目设计完整的类型结构,创建多个 .d.ts 文件并配置 tsconfig paths。

// 项目结构:
// src/
//   types/
//     env.d.ts       —— 声明 process.env
//     express.d.ts   —— 扩展 Express Request
//     models.d.ts    —— 数据模型类型
//   routes/
//     users.ts
//   index.ts

// 1. types/env.d.ts —— 声明环境变量
// 2. types/express.d.ts —— 给 Request 添加 userId
// 3. types/models.d.ts —— 导出 User、Post 类型
// 4. tsconfig.json —— 配置 @/* 路径别名

参考答案

// types/env.d.ts
export {};
declare namespace NodeJS {
    interface ProcessEnv {
        NODE_ENV: "development" | "production";
        PORT?: string;
        DATABASE_URL: string;
        JWT_SECRET: string;
    }
}

// types/express.d.ts
export {};
declare module "express" {
    interface Request {
        userId?: string;
        userRole?: "admin" | "user";
    }
}

// types/models.d.ts
export interface User {
    id: string;
    name: string;
    email: string;
    role: "admin" | "user";
}
export interface Post {
    id: string;
    title: string;
    content: string;
    authorId: string;
}

// tsconfig.json(关键部分)
// "baseUrl": ".",
// "paths": { "@/*": ["src/*"] }

常见问题

问题一:找不到模块声明

// 导入一个没有类型的库时报错
import lodash from "lodash"; // 报错!找不到声明文件

解决:安装 @types/lodash 或创建自定义 .d.ts。

问题二:declare global 不生效

// .d.ts 文件中 declare global 没有效果
// 原因:文件缺少 export {} 导致它不是模块
// 解决:添加 export {};
export {};
declare global {
    interface Window { myProp: string; }
}

问题三:import type 和 import 混淆

// 错误:用 import type 导入值
import type { getUser } from "./utils"; // 报错!getUser 是值不是类型

// 正确:用 type 标记类型,不标记值
import { type User, getUser } from "./utils";

试一试

练习一

任务:创建一个 types/env.d.ts 文件,声明 process.env 的类型(包含 PORT、NODE_ENV、DATABASE_URL)。

// types/env.d.ts
// 你的代码:

参考答案

declare namespace NodeJS {
    interface ProcessEnv {
        NODE_ENV: "development" | "production" | "test";
        PORT?: string;
        DATABASE_URL: string;
    }
}

练习二

任务:为一个名为 my-lib 的无类型库手写声明文件,它导出 format(date: Date): string 和 VERSION: string。

// types/my-lib.d.ts
// 你的代码:

参考答案

declare module "my-lib" {
    export function format(date: Date): string;
    export const VERSION: string;
}

本章要点

模块与声明文件知识体系回顾

主题核心概念一句话总结
类型导入导出import type / export type用内联 type 标记最清晰
declare全局声明 / 模块扩展告诉 TS 运行时存在什么
.d.ts声明文件编写纯类型,不输出 JS
补充类型@types / 手写声明优先库自带类型
ESM 与 CJS互操作 / 扩展名新项目优先 ESM
模块解析node16 / bundlerNode 用 node16,前端用 bundler
循环依赖提取 / 延迟导入用 madge 检测,提取共享类型解决
最佳实践isolatedModules / re-export开启 isolatedModules 是底线

📝 要点总结

本章核心知识点

模块语法

ESM export/import、默认导出与命名导出、type 关键字

声明文件

.d.ts 编写、declare 关键字、全局类型扩展

解析策略

Node 经典解析、Bundler 现代解析、paths 路径别名

CJS 与 ESM

互操作规则、默认导入陷阱、esModuleInterop

最佳实践

Barrel 模式、循环依赖处理、包 exports 配置

下一章

模块和声明文件让类型在项目中流通,ESM/CJS 互操作与循环依赖处理是进阶必备。下一章我们学习前端工程化基础——让开发流程更专业。

1 / X