Skip to content

模块系统与解析策略深入

一句话概述:CJS vs ESM、moduleResolution 策略、package.json exports、verbatimModuleSyntax——理解 TypeScript 模块解析的完整链路

为什么模块解析这么复杂?

JavaScript 模块系统经历了多个阶段的演进:

text
全局变量 → IIFE → AMD/CMD → CommonJS → ES Modules

TypeScript 需要同时支持多种模块系统,并在编译期正确解析每种 import 语句指向的文件和类型。2026 年的复杂性主要来自 CJS 与 ESM 的共存以及 Node.js 与打包器的行为差异


一、CJS vs ESM 核心区别

CommonJS(CJS)

javascript
// 导出
module.exports = { add, subtract };
// 或
exports.add = function(a, b) { return a + b; };

// 导入
const { add } = require('./math');
const fs = require('fs');

特征

  • 同步加载require() 是同步的
  • 运行时解析:导入路径可以是动态表达式
  • 值拷贝:导入的是值的拷贝,不是引用
  • package.json"main" 字段指定入口

ES Modules(ESM)

javascript
// 导出
export function add(a, b) { return a + b; }
export default class Calculator {}

// 导入
import { add } from './math.js';
import Calculator from './calculator.js';

特征

  • 异步加载:支持 top-level await
  • 静态分析:导入路径必须是字符串字面量,利于 tree-shaking
  • 活绑定(Live Binding):导入的是引用,非值拷贝
  • package.json"exports" 字段指定入口

关键差异对比

typescript
// ===== 值拷贝 vs 活绑定 =====

// CJS:值拷贝
// counter.cjs
let count = 0;
module.exports = { count, increment: () => ++count };

// main.cjs
const { count, increment } = require('./counter.cjs');
increment();
console.log(count); // 0(拷贝不变)

// ESM:活绑定
// counter.mjs
export let count = 0;
export function increment() { count++; }

// main.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1(引用更新)

// ===== 默认导出的互操作 =====

// CJS 模块
module.exports = function hello() {};

// ESM 中导入 CJS 默认导出
import hello from './hello.cjs';          // ✅ Node.js 支持
import { default as hello } from './hello.cjs'; // ✅ 等价
import * as hello from './hello.cjs';     // hello.default 才是函数

二、Node.js 的模块解析

Node.js 如何判断 CJS 还是 ESM?

text
判断顺序:
1. 文件扩展名
   ├── .cjs → CommonJS
   ├── .mjs → ES Module
   └── .js  → 看 package.json 的 "type" 字段
2. package.json 的 "type" 字段
   ├── "type": "module"     → .js 按 ESM 处理
   ├── "type": "commonjs"   → .js 按 CJS 处理(默认)
   └── 不写                  → .js 按 CJS 处理

package.jsonexports 字段

exports 是 Node.js 12.11+ 引入的模块入口点定义,取代旧的 main 字段:

json
{
  "name": "my-lib",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs",
      "default": "./dist/index.mjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.mjs",
      "require": "./dist/utils.cjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.mjs",
  "types": "./dist/index.d.ts"
}

条件导出的优先级(从上到下匹配):

json
{
  "exports": {
    ".": {
      "types": "...",      // TypeScript 类型(必须放第一位)
      "node": "...",       // Node.js 环境
      "import": "...",     // ESM import
      "require": "...",    // CJS require
      "default": "..."    // 兜底
    }
  }
}

重要"types" 条件必须放在第一位,否则 TypeScript 可能找不到类型声明。

Node.js ESM 中的路径规则

typescript
// Node.js ESM 模式下的严格规则:

// ✅ 必须写完整扩展名
import { add } from './math.js';     // 即使源文件是 .ts
import config from './config.json' with { type: 'json' };

// ❌ 不能省略扩展名(CJS 可以,ESM 不行)
import { add } from './math';        // Error

// ❌ 不能导入目录(CJS 的 index.js 规则不适用)
import utils from './utils';          // Error
import utils from './utils/index.js'; // ✅ 必须写完整

三、TypeScript 的 moduleResolution 策略

"bundler"(2026 前端项目首选)

json
{
  "moduleResolution": "bundler",
  "module": "ESNext"
}

行为

  • 支持 package.jsonexports 字段
  • 不强制文件扩展名(因为打包器会处理)
  • 支持 paths 别名
  • 不支持 require()(纯 ESM)
typescript
// bundler 模式下都可以
import { add } from './math';       // ✅ 省略扩展名
import { add } from './math.js';    // ✅ 写扩展名也行
import utils from './utils';         // ✅ 自动找 index.ts

"node16" / "nodenext"(Node.js 后端首选)

json
{
  "moduleResolution": "nodenext",
  "module": "NodeNext"
}

行为

  • 严格遵循 Node.js ESM 解析规则
  • 必须写文件扩展名.js,即使源文件是 .ts
  • 支持 exports 字段
  • .ts 文件中 import 时要写 .js 扩展名(TS 编译时不重写扩展名)
typescript
// nodenext 模式下
import { add } from './math.js';     // ✅ 必须写 .js(源文件是 math.ts)
// import { add } from './math';     // ❌ 报错
// import { add } from './math.ts';  // ❌ 报错

// TS 5.7+ 的 --rewriteRelativeImportExtensions 可以自动处理

"node"(旧版 CJS 模式)

json
{
  "moduleResolution": "node",
  "module": "CommonJS"
}

行为

  • 经典 Node.js require() 解析算法
  • 自动查找 index.ts/js
  • 不支持 exports 字段
  • 逐步被 nodenext 取代

策略选择决策树

text
你的项目类型?
├── 前端(Vite/Webpack/Next.js)
│   └── moduleResolution: "bundler"
├── Node.js 后端(ESM)
│   └── moduleResolution: "nodenext"
├── Node.js 后端(CJS,旧项目)
│   └── moduleResolution: "node"
└── npm 库
    ├── 只给前端用 → "bundler"
    └── 也给 Node.js 用 → "nodenext"

四、verbatimModuleSyntax 深入

背景

TypeScript 需要决定哪些 import 在输出的 JavaScript 中保留、哪些移除。旧版有三个相关选项:

  • isolatedModules:确保每个文件可以独立编译
  • importsNotUsedAsValues:控制纯类型导入的行为
  • preserveValueImports:保留可能有副作用的导入

TS 5.0 用 verbatimModuleSyntax 一个选项取代了它们。

规则

typescript
// verbatimModuleSyntax: true

// 规则:import type 会被完全移除,import 会被保留

// ✅ 类型导入:编译后移除
import type { User } from './types';

// ✅ 值导入:编译后保留
import { createUser } from './utils';

// ✅ 混合导入
import { createUser, type User } from './user';
// 编译后:import { createUser } from './user';

// ❌ 错误:如果 User 只是类型,必须用 import type
import { User } from './types'; // Error: User is only used as a type

// ✅ 副作用导入(只执行模块代码)
import './polyfill';

// ✅ export type 同理
export type { User };     // 编译后移除
export { createUser };    // 编译后保留

isolatedModules 的关系

text
verbatimModuleSyntax 涵盖了 isolatedModules 的所有限制,且更严格:

isolatedModules 的限制(仍然适用):
├── 不能使用 const enum(跨文件内联需要全局分析)
├── 不能使用 namespace 合并
└── 每个文件必须是模块(有 import/export)

verbatimModuleSyntax 额外要求:
├── 纯类型导入必须用 import type
├── 纯类型导出必须用 export type
└── re-export 类型必须用 export type

五、声明文件解析

TypeScript 如何找到类型声明?

text
import { something } from 'my-lib';

TypeScript 查找类型声明的顺序:
1. package.json 的 "exports"."types" 条件
2. package.json 的 "types" 或 "typings" 字段
3. 包目录下的 index.d.ts
4. @types/my-lib 包(node_modules/@types/my-lib)
5. typeRoots 配置的目录

typeRootstypes

json
{
  "compilerOptions": {
    // 指定类型声明搜索的根目录(默认 node_modules/@types)
    "typeRoots": [
      "./node_modules/@types",
      "./src/types"        // 自定义类型目录
    ],

    // 只包含指定的 @types 包(默认包含所有)
    "types": [
      "node",              // @types/node
      "jest"               // @types/jest
    ]
  }
}

自定义声明文件

typescript
// src/types/env.d.ts — 环境变量类型
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'production' | 'test';
    DATABASE_URL: string;
    API_KEY: string;
  }
}

// src/types/assets.d.ts — 静态资源类型
declare module '*.svg' {
  import type { FC, SVGProps } from 'react';
  const SVGComponent: FC<SVGProps<SVGSVGElement>>;
  export default SVGComponent;
}

declare module '*.css' {
  const classes: Record<string, string>;
  export default classes;
}

declare module '*.png' {
  const src: string;
  export default src;
}

// src/types/global.d.ts — 全局类型扩展
declare global {
  interface Window {
    __APP_VERSION__: string;
    gtag: (...args: any[]) => void;
  }
}

export {}; // 确保这个文件被当作模块

六、库开发的模块策略

Dual Package(同时支持 CJS 和 ESM)

json
{
  "name": "my-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./utils": {
      "types": "./dist/utils.d.ts",
      "import": "./dist/utils.js",
      "require": "./dist/utils.cjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"]
}

构建工具选择

text
TypeScript 库的构建方案(2026):

tsc                   → 只生成 .d.ts(emitDeclarationOnly)
 +
tsup / unbuild        → 生成 .js + .cjs(基于 esbuild/rollup)

或者:

tsdown(新兴)        → 一体化方案,基于 Rolldown
bash
# tsup 示例
npx tsup src/index.ts --format esm,cjs --dts
# 输出:dist/index.js + dist/index.cjs + dist/index.d.ts

常见陷阱

typescript
// 1. CJS 默认导出问题
// 如果你的库有 export default
export default function myLib() {}

// CJS 用户需要:
const myLib = require('my-lib').default; // 注意 .default

// 解决:用 esModuleInterop 或避免 default export

// 2. 类型声明路径问题
// exports 中 "types" 条件必须在第一位
{
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",  // ✅ 第一位
      "import": "./dist/index.js"
    }
  }
}

// 3. .d.ts 和 .d.cts / .d.mts
// 如果同时输出 ESM 和 CJS,可能需要不同的类型声明
{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    }
  }
}

面试高频题

1. CJS 和 ESM 的核心区别是什么?

答案:CJS 是同步加载、运行时解析、值拷贝;ESM 是异步加载(支持 top-level await)、静态分析(利于 tree-shaking)、活绑定(导入是引用非拷贝)。CJS 用 require/module.exports,ESM 用 import/export。Node.js 通过文件扩展名(.cjs/.mjs)和 package.jsontype 字段判断模块类型。

2. moduleResolution: "bundler""nodenext" 怎么选?

答案:前端项目(Vite/Webpack/Next.js)用 bundler,因为打包器会处理模块解析,不需要写扩展名。Node.js 后端项目用 nodenext,严格遵循 Node.js ESM 规则,必须写 .js 扩展名。bundler 更宽松,nodenext 更严格。

3. 为什么 import 时要写 .js 而不是 .ts

答案:在 nodenext 模式下,TypeScript 遵循 Node.js 的解析规则,而 Node.js 运行的是编译后的 .js 文件。TypeScript 编译器不会重写导入路径的扩展名,所以源码中 import 的路径必须指向最终运行时的文件。.ts 不是 Node.js 认识的扩展名。TS 5.7+ 的 --rewriteRelativeImportExtensions 可以自动重写。

4. package.jsonexports 字段有什么作用?

答案exports 定义了包的公共 API 入口点,取代旧的 main/module/types 字段。它支持条件导出(ESM/CJS/types/不同环境),可以限制包的内部文件不被外部直接导入(封装性),是 Node.js 12.11+ 的标准特性。TypeScript 在 bundlernodenext 模式下都支持读取 exports 字段。

5. verbatimModuleSyntax 解决了什么问题?

答案:它明确了 TypeScript 编译后哪些 import 保留、哪些移除——import type 的导入一定被移除,普通 import 一定被保留,所见即所得。这对 esbuild、SWC 等只做语法转换不做类型分析的工具至关重要,它们无法判断一个 import 是类型还是值,import type 提供了明确的信号。