本节目标:当你的代码是 ES 模块、依赖的包却是 CommonJS 时,理解运行时和类型系统各自做了什么转换。读完你能解释
esModuleInterop为什么几乎必开、import x from "cjs 包"为什么有时拿到undefined,以及__dirname在 ESM 里为什么消失。
11.2 ESM/CJS 互操作与 moduleResolution
上一节我们把 ES 模块当成唯一主角。但 Node 生态里有一个无法回避的事实:CommonJS(CJS)已经存在了十多年,npm 上仍有海量包是 CJS 写的,而且短期内不会消失。
于是每个真实项目都活在两套模块系统之间。本节要讲清楚三个问题:一个文件怎么被判定是 ESM 还是 CJS;两套系统怎么互相调用;TypeScript 用哪些开关来模拟这套行为。
11.2.1 CommonJS 长什么样
先把 CJS 的语法摆在旁边对照:
// utils.cjs —— CommonJS 风格
const path = require("node:path"); // 导入
function join(...parts) {
return path.join(...parts);
}
module.exports = { join }; // 导出
对照 ESM 版本:
// utils.ts —— ES 模块风格
import path from "node:path"; // 导入
export function join(...parts: string[]) {
return path.join(...parts);
}
表面差异只是关键字,底层的差异却是结构性的:
| 维度 | CommonJS | ES 模块 |
|---|---|---|
| 解析时机 | 运行时,require 是普通函数 | 编译期,静态分析 |
| 导出形式 | 单个 module.exports 对象 | 一组具名绑定 + 一个默认导出 |
| 加载方式 | 同步 | 异步(Node 里也基本是同步求值,但语义上允许异步) |
顶层 this | module.exports | undefined |
| 变量提升 | 无 | 导入绑定提升 |
| tree-shaking | 基本不可能 | 支持 |
关键在第一行:require 是函数,import 是声明。前者可以在 if 里调用、可以拼接字符串路径;后者必须静态可分析。这一条决定了后面所有的互操作难题。
11.2.2 package.json 的 type 字段
Node 怎么知道一个 .js 文件该按哪套规则解析?答案是就近的 package.json 里的 type 字段:
{
"name": "my-app",
"type": "module"
}
规则只有三条,但必须记牢:
"type": "module"→ 该目录下所有.js文件按 ESM 解析。"type": "commonjs"或不写type→ 按 CJS 解析(这是默认值)。- 扩展名永远优先于
type:.mjs一定是 ESM,.cjs一定是 CJS。
第三点是应急出口:如果你在 "type": "module" 的项目里必须放一个 CJS 文件,把它命名为 .cjs 即可;反之在 CJS 项目里放一个 ESM 文件,命名 .mjs。
my-app/
package.json type: "module"
index.js 按 ESM 解析
legacy.cjs 按 CJS 解析(扩展名优先)
sub/
package.json type: "commonjs"
worker.js 按 CJS 解析(就近的 package.json 覆盖上层)
注意 sub/ 的例子:type 的作用域是目录树,可以嵌套覆盖。这就是「同一个仓库里两种模块格式并存」的官方支持方式。
11.2.3 TypeScript 侧的扩展名映射
TypeScript 源码不能直接用 .mjs/.cjs(那会被当成 JavaScript),它提供了三个新扩展名来表达同样的意图:
| 源码扩展名 | 编译产物 | 模块类型 |
|---|---|---|
.ts | .js | 由 package.json 的 type 决定 |
.mts | .mjs | 强制 ESM |
.cts | .cjs | 强制 CJS |
对应地,导入路径也要跟着改:
// 在 .mts 文件里导入同目录的 .mts 文件
import { join } from "./utils.mjs"; // 写产物扩展名 .mjs
如果你在 "type": "module" 的项目里写 .ts,那它等价于 .mts,导入路径同样要写 .js。这正是上一节那条「源码写 .js、磁盘是 .ts」规则的完整解释:导入路径描述的是运行时会加载的文件名,而不是你现在编辑的文件名。
11.2.4 从 ESM 导入 CJS:默认导出即 module.exports
这是最常见的场景:你的项目是 ESM,用了某个只发布 CJS 的包。
// 你的代码是 ESM
import express from "express"; // express 是 CJS 包
import { Router } from "express";
Node 的 ESM 加载器做了一个「尽力而为」的转换:把 CJS 的 module.exports 整体当作 ESM 的默认导出。所以 import express from "express" 拿到的就是那个函数对象。
同时 Node 还会用静态分析(cjs-module-lexer)扫描 module.exports 上挂了哪些属性,把它们暴露成具名导出,于是 import { Router } from "express" 也能用。
但这里有两个陷阱。
陷阱一:具名导出是「猜」出来的。 如果包用动态方式挂属性,静态分析就扫不到:
// 某个 CJS 包的源码
const api = {};
["get", "post", "put"].forEach((m) => {
api[m] = (url, fn) => { /* ... */ };
});
module.exports = api;
此时 import { get } from "that-package" 在运行时可能报:
SyntaxError: Named export 'get' not found. The requested module 'that-package'
is a CommonJS module, which may not support all module.exports as named exports.
改法是退回默认导入再解构:import pkg from "that-package"; const { get } = pkg;。
陷阱二:默认导出到底是不是 module.exports。 如果这个 CJS 包同时设置了 exports.__esModule = true 和 exports.default(Babel/TS 编译出来的老包常这么干),那默认导入拿到的是 exports.default 而不是整个 exports。这个分歧正是下面要讲的 esModuleInterop 要处理的。
11.2.5 esModuleInterop 到底改了什么
esModuleInterop 是 tsconfig.json 里最重要的互操作开关,几乎所有项目都开着。但它「改了什么」常被误解。
它不改变运行时行为,它改变的是类型系统对 CJS 包形状的描述,并配合输出一层包装。开启后:
// esModuleInterop: true
import express from "express"; // 合法,默认导入被允许
关闭时,同样的代码会报:
error TS1259: Module '"express"' can only be default-imported using the
'esModuleInterop' flag
编译器为了让默认导入在运行时真的能拿到东西,会在产物里插入一个 __importDefault 辅助函数(importHelpers 开启时从 tslib 引入):
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
读一下这个函数就明白了一切:如果模块自带 __esModule 标记,就用它自己的 default;否则把整个 module.exports 包成 { default: ... }。 这就是 import x from "cjs 包" 能工作的全部机制。
与它配套的还有两个开关:
| 选项 | 作用 | 建议 |
|---|---|---|
esModuleInterop | 允许默认导入 CJS 包,并影响产物辅助函数 | 开启 |
allowSyntheticDefaultImports | 只放宽类型检查,不改变产物 | 由 esModuleInterop 自动隐含 |
verbatimModuleSyntax | 输出与源码严格一一对应,禁止跨系统隐式转换 | 新项目建议开启 |
关于最后一项值得多说一句。verbatimModuleSyntax 开启后,你写 import 就输出 import,写 require 就输出 require,编译器不再替你猜测和改写。这对库作者特别有价值,因为产物形状变得可预测。代价是 CJS 文件里写 ESM 语法会直接报错,你必须显式使用:
// 在 .cts 文件里引入 ESM 包,必须用这种 CJS 专有语法
import fs = require("node:fs");
import x = require("y") 是 TypeScript 特有的写法,只在 CJS 上下文里合法。它不像 import 那样提升,等价于 const x = require("y"),但保留类型信息。
11.2.6 从 CJS 导入 ESM:只能动态导入
反方向就严格多了:CJS 不能用 require 同步加载 ESM。原因是 ESM 的求值可能是异步的(顶层 await),而 require 是同步函数。
从 Node 20.17 起,require() 可以在特定条件下加载同步的 ESM,但这条路径限制极多(不能有顶层 await、不能依赖异步加载器),工程上不建议依赖。
可靠的做法只有一个——动态 import(),它返回 Promise:
// 某个 .cjs 文件里
async function loadEsm() {
const mod = await import("some-esm-only-package");
return mod.doThing();
}
注意 import() 是表达式,在 CJS 里也合法,这是 CJS 通往 ESM 的唯一官方桥梁。
11.2.7 ESM 里没有 __dirname
CJS 注入了一批「魔法变量」:__dirname、__filename、require、module、exports。它们都是 CJS 包装函数传进来的参数,ESM 里一个都不存在。
所以在 ESM 里写 __dirname 会直接抛:
ReferenceError: __dirname is not defined in ES module scope
替代方案是用 import.meta.url 换算:
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const dataPath = join(__dirname, "data.json");
import.meta.url 是 ESM 独有的元属性,值是当前模块的 file:// URL。从 Node 20.11 起还可以直接用 import.meta.dirname 与 import.meta.filename,省掉上面两行换算——但要在 tsconfig.json 里把 module 设成 nodenext 才会被识别,否则会报 Property 'dirname' does not exist on type 'ImportMeta'。
11.2.8 双包危害
当一个包同时提供 ESM 和 CJS 两份产物时,会出现双包危害(dual package hazard):如果同一次运行中,一部分代码走 ESM 入口、另一部分走 CJS 入口,这两份模块实例是各自独立的。
后果是模块级状态被复制:
// 包内部有一个模块级单例
let counter = 0;
export function increment() { return ++counter; }
ESM 路径调两次 increment() 得到 1、2;CJS 路径也得到 1、2——但它们是两套 counter。如果你以为拿到的是同一个单例(比如连接池、缓存、事件总线),就会出现「明明设置过了却读不到」的诡异现象。
规避办法是把状态提到单独的、只有一份实例的模块,或者干脆只发布一种格式。这一点在 11.3 npm 包、类型声明与 exports 讲包作者视角时还会回来。
11.2.9 import type 与类型导入
模块互操作还有一个纯类型层面的问题:有些导入在运行时根本不需要存在。
import { User } from "./types"; // User 只用作类型
function greet(u: User) { /* ... */ }
编译时,User 会被擦除,但 import 语句本身在默认配置下仍会保留(因为编译器不确定它是不是只在类型位置使用)。如果 ./types 在运行时不存在,就会报 Cannot find module。
两个解法:
// 方案一:显式标注只导入类型
import type { User } from "./types";
// 方案二:行内标注
import { type User, createUser } from "./types";
import type 保证这条语句在产物里被完全删除。它还有一个额外好处:在 isolatedModules 或 verbatimModuleSyntax 开启时是必需的,因为这两个开关都要求单文件可独立编译,编译器无法跨文件判断某个导入是否只用于类型。
| 选项 | 含义 | 与互操作的关系 |
|---|---|---|
isolatedModules | 保证每个文件能被独立转译 | 要求类型导入必须显式标注 |
verbatimModuleSyntax | 产物与源码导入形式一一对应 | 强制区分 import type 与 import |
importsNotUsedAsValues | 旧选项,已被上者取代 | 不再使用 |
现代打包器(esbuild、swc)都是逐文件转译的,它们看不到别的文件,所以 isolatedModules 实际上是「用打包器时必须开」的选项。这也是 import type 从一个可选优化变成团队规范的原因。
11.2.10 四类常见报错
Module '"x"' can only be default-imported using the 'esModuleInterop' flag
CJS 包配默认导入,但 esModuleInterop 没开。开启即可,不要改用 import * as x 硬凑。
Named export 'x' not found. The requested module 'y' is a CommonJS module...
CJS 包的导出是动态构造的,Node 的静态分析扫不出具名导出。改为默认导入再解构。
ReferenceError: require is not defined in ES module scope
在 "type": "module" 的项目里用了 require。改用 import,或把文件改成 .cjs。
Dynamic require of "x" is not supported
打包器(如 esbuild 的 ESM 输出)无法处理动态拼接的 require 路径。改成静态 import 或 import()。
11.2.11 配置速查
把本节内容收敛成两张配置,供不同场景直接取用:
// 场景 A:Node 直接运行的库或服务(产物是 ESM)
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"skipLibCheck": true,
"strict": true
}
}
// 场景 B:交给 Vite / esbuild 打包的应用
{
"compilerOptions": {
"target": "ES2020",
"module": "esnext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"isolatedModules": true,
"noEmit": true,
"strict": true
}
}
场景 B 里的 noEmit: true 值得注意:打包器负责出产物,tsc 只负责类型检查,这是现代前端项目的主流分工。第 16 章会专门讲这套流水线。
小结
本节把两套模块系统摆在一起对照:CommonJS 的 require 是运行时函数、导出是单个对象;ES 模块的 import 是编译期声明、结构静态可分析。这个根本差异解释了后面所有的限制。
我们随后确认了模块类型的判定规则——package.json 的 type 字段决定目录树的默认值,.mjs/.cjs 扩展名永远优先,TypeScript 侧对应 .mts/.cts。互操作的核心机制是「CJS 的 module.exports 即 ESM 的默认导出」,而 esModuleInterop 生成的 __importDefault 辅助函数正是这一转换的落地实现;反向则只能靠动态 import()。最后我们点出了 __dirname 在 ESM 中缺失、双包危害导致单例被复制、以及 import type 在逐文件转译下成为必需这几个实战要点。
到这里我们一直在「使用」别人的包。下一节 11.3 npm 包、类型声明与 exports
会换到作者视角:一个包如何用 exports 条件导出同时支持 ESM 与 CJS、类型声明该怎么放置、发布时又该注意什么。
阅读导航:上一节:11.1 ES 模块与模块解析 · 下一节:11.3 npm 包、类型声明与 exports 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。