模块系统与解析策略深入
一句话概述:CJS vs ESM、moduleResolution 策略、package.json exports、verbatimModuleSyntax——理解 TypeScript 模块解析的完整链路
为什么模块解析这么复杂?
JavaScript 模块系统经历了多个阶段的演进:
全局变量 → IIFE → AMD/CMD → CommonJS → ES ModulesTypeScript 需要同时支持多种模块系统,并在编译期正确解析每种 import 语句指向的文件和类型。2026 年的复杂性主要来自 CJS 与 ESM 的共存以及 Node.js 与打包器的行为差异。
一、CJS vs ESM 核心区别
CommonJS(CJS)
// 导出
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)
// 导出
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"字段指定入口
关键差异对比
// ===== 值拷贝 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?
判断顺序:
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.json 的 exports 字段
exports 是 Node.js 12.11+ 引入的模块入口点定义,取代旧的 main 字段:
{
"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"
}条件导出的优先级(从上到下匹配):
{
"exports": {
".": {
"types": "...", // TypeScript 类型(必须放第一位)
"node": "...", // Node.js 环境
"import": "...", // ESM import
"require": "...", // CJS require
"default": "..." // 兜底
}
}
}重要:
"types"条件必须放在第一位,否则 TypeScript 可能找不到类型声明。
Node.js ESM 中的路径规则
// 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 前端项目首选)
{
"moduleResolution": "bundler",
"module": "ESNext"
}行为:
- 支持
package.json的exports字段 - 不强制文件扩展名(因为打包器会处理)
- 支持
paths别名 - 不支持
require()(纯 ESM)
// bundler 模式下都可以
import { add } from './math'; // ✅ 省略扩展名
import { add } from './math.js'; // ✅ 写扩展名也行
import utils from './utils'; // ✅ 自动找 index.ts"node16" / "nodenext"(Node.js 后端首选)
{
"moduleResolution": "nodenext",
"module": "NodeNext"
}行为:
- 严格遵循 Node.js ESM 解析规则
- 必须写文件扩展名(
.js,即使源文件是.ts) - 支持
exports字段 .ts文件中import时要写.js扩展名(TS 编译时不重写扩展名)
// nodenext 模式下
import { add } from './math.js'; // ✅ 必须写 .js(源文件是 math.ts)
// import { add } from './math'; // ❌ 报错
// import { add } from './math.ts'; // ❌ 报错
// TS 5.7+ 的 --rewriteRelativeImportExtensions 可以自动处理"node"(旧版 CJS 模式)
{
"moduleResolution": "node",
"module": "CommonJS"
}行为:
- 经典 Node.js
require()解析算法 - 自动查找
index.ts/js - 不支持
exports字段 - 逐步被
nodenext取代
策略选择决策树
你的项目类型?
├── 前端(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 一个选项取代了它们。
规则
// 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 的关系
verbatimModuleSyntax 涵盖了 isolatedModules 的所有限制,且更严格:
isolatedModules 的限制(仍然适用):
├── 不能使用 const enum(跨文件内联需要全局分析)
├── 不能使用 namespace 合并
└── 每个文件必须是模块(有 import/export)
verbatimModuleSyntax 额外要求:
├── 纯类型导入必须用 import type
├── 纯类型导出必须用 export type
└── re-export 类型必须用 export type五、声明文件解析
TypeScript 如何找到类型声明?
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 配置的目录typeRoots 和 types
{
"compilerOptions": {
// 指定类型声明搜索的根目录(默认 node_modules/@types)
"typeRoots": [
"./node_modules/@types",
"./src/types" // 自定义类型目录
],
// 只包含指定的 @types 包(默认包含所有)
"types": [
"node", // @types/node
"jest" // @types/jest
]
}
}自定义声明文件
// 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)
{
"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"]
}构建工具选择
TypeScript 库的构建方案(2026):
tsc → 只生成 .d.ts(emitDeclarationOnly)
+
tsup / unbuild → 生成 .js + .cjs(基于 esbuild/rollup)
或者:
tsdown(新兴) → 一体化方案,基于 Rolldown# tsup 示例
npx tsup src/index.ts --format esm,cjs --dts
# 输出:dist/index.js + dist/index.cjs + dist/index.d.ts常见陷阱
// 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.json 的 type 字段判断模块类型。
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.json 的 exports 字段有什么作用?
答案:exports 定义了包的公共 API 入口点,取代旧的 main/module/types 字段。它支持条件导出(ESM/CJS/types/不同环境),可以限制包的内部文件不被外部直接导入(封装性),是 Node.js 12.11+ 的标准特性。TypeScript 在 bundler 和 nodenext 模式下都支持读取 exports 字段。
5. verbatimModuleSyntax 解决了什么问题?
答案:它明确了 TypeScript 编译后哪些 import 保留、哪些移除——import type 的导入一定被移除,普通 import 一定被保留,所见即所得。这对 esbuild、SWC 等只做语法转换不做类型分析的工具至关重要,它们无法判断一个 import 是类型还是值,import type 提供了明确的信号。