ES Modules 和 CommonJS 的互操作问题,几乎每个 TypeScript 项目都会碰到,但大多数人只记住了esModuleInterop: true这个开关,真要解释清楚它做了什么、为什么有些包在 Node ESM 里只能 default import、以及moduleResolution升级时类型为什么会突然崩,往往就说不清楚了。尤其是 default interop 这条线,运行时行为、转译器行为、类型系统行为是三层不同的逻辑,很多“编译过了但运行报错”的诡异问题,根源都在于这三层被混为一谈。这篇文章就从 ESM/CJS 互操作的运行时链路讲起,把 default interop 的完整策略、TypeScript 类型系统的差异、以及 node10 到 nodenext 的迁移影响一次说透,适合正在从 CJS 迁移到 ESM、或者被 TS 7.0 弃用警告困扰的开发者参考。
1. 一个 default 导入为什么会有两套判断标准
1.1 CJS 的“导出即对象”与 ESM 的“导出即声明”
先说根源。CommonJS 的设计非常直白:模块就是一个module.exports对象,你可以随时给它挂属性,也可以整个换掉:
// cjs-dynamic.js module.exports = function () { return "hello"; }; module.exports.extra = 42;这种写法是动态的,加载器在运行时拿到的是最终那个对象,至于它上面有哪些属性,Node 不关心,也不需要提前声明。
而 ESM 完全反过来,export语句是静态的。引擎在解析阶段就需要确定这个模块对外暴露了哪些名字:
// esm-static.mjs export function greet() {} export default function () {}静态分析意味着每个导出名都必须是字面量声明,不能写export someFunction()这种条件导出。两套设计哲学天然不兼容:ESM 需要“清单”,CJS 只给“实物”。
1.2 类型系统里的 default 又是另一套逻辑
问题在这里开始复杂化。TypeScript 类型检查器看的并不是运行时对象,而是.d.ts声明文件。声明文件里可能写export = React、export default Foo,也可能既有命名导出又有默认导出。TS 会根据tsconfig里的开关决定“这个 CJS 模块是不是有一个 default 导出可以让我 import”。
于是同一个包就出现了两条判断标准:
- 运行时标准:Node 加载器决定
import pkg from "some-cjs"拿到的值是什么。 - 类型标准:TypeScript 决定
import pkg from "some-cjs"能不能通过类型检查,以及 pkg 的类型是什么。
这两者并不总是对齐。典型例子是@types/node里fs的声明:export = fs,同时底层 CJS 模块没有__esModule标记。在 Node ESM 环境里,import fs from "fs"合法,因为 Node 会把module.exports整体包装成 default;但在 TypeScript 里,如果不开esModuleInterop,这条语句直接报 TS1259。这就是“同一个写法,两个世界”最直观的体现。
2. Node 加载器视角:ESM 如何把 CJS 翻译成自己认识的形状
2.1 默认导出就是 module.exports 本体
Node 处理 ESM 导入 CJS 模块的规则很质朴:CJS 模块的module.exports值,整体作为 default 导出暴露给 ESM。
// cjs-module.js module.exports = function add(a, b) { return a + b; };// importer.mjs import add from "./cjs-module.js"; console.log(add(1, 2)); // 3这里没有花哨的包装,default 引用的就是那个函数本身。如果module.exports是一个对象,default 就是这个对象;如果模块什么都没导出,default 就是{}。
这条规则也是import React from "react"能原生跑起来的原因之一:React 的主入口是一个没有__esModule标记的 CJS 模块,module.exports本身就是包含所有 API 的 React 对象,Node 把它整体映射成 default,自然得到一个“React 对象”。
2.2 命名导出从哪来:cjs-module-lexer 的静态扫描
那import { debounce } from "lodash"这种命名导出呢?CJS 模块并没有声明过命名导出,Node 只能猜。
Node 在加载 CJS 模块供 ESM 使用时,会用cjs-module-lexer对源码做一次词法扫描,识别常见的导出模式:
exports.name = ...module.exports.name = ...Object.defineProperty(exports, "name", ...)Object.defineProperty(module.exports, "name", ...)
检测到的名字会作为 ESM 命名导出暴露出来,检测不到的就被忽略。
这带来一个非常实际的限制:动态导出的属性很可能检测不到。比如:
// pkg/index.js const api = {}; for (const key of ["a", "b", "c"]) { api[key] = () => key; } module.exports = api;cjs-module-lexer看到的是for循环和module.exports = api,无法推断出a、b、c这些命名导出。此时import { a } from "pkg"会在 Node 里报Named export 'a' not found,只有import pkg from "pkg"能拿到整个 api 对象。
这不只是理论场景。很多老牌 npm 包的实际构建产物就是这样,这也是为什么社区里一直有“CJS 包尽量 default import,不要依赖命名导出”的说法——命名导出能不能用,取决于 Node 的扫描器认不认得那个写法。
2.3__esModule标记如何改变 default 的取值
还有一个约定叫__esModule,是 TypeScript 和 Babel 在把 ESM 转译成 CJS 时种下的标记:
Object.defineProperty(exports, "__esModule", { value: true }); exports.default = function greet() { return "hello"; };Node 的 CJS 转 ESM 逻辑里有一个重要分支:如果 CJS 模块带有__esModule: true,default 导出就不再取module.exports整体,而是取module.exports.default。也就是说,Node 会“尊重”这个模块原本设计好的 ESM 默认导出,而不是强行把整个 exports 对象塞给你。
这个细节解释了为什么有的包在两种加载方式下表现一致:
- 手写 CJS、没有
__esModule:default = 整个module.exports。 - TS/Babel 转译产物、有
__esModule:default =module.exports.default,和其他转译代码的require("pkg").default访问完全对齐。
假如一个模块的__esModule标记与它的module.exports.default不一致,那就会出现“在原生 ESM 里拿到 A,在 TS 编译的 CJS 里拿到 B”的割裂。这种包虽然不多,但每次遇到都极其难排查。
2.4 CJS 模块形态在原生 ESM 里的可见性对照
| CJS 模块形态 | 是否有__esModule | 原生 ESM default 的值 | 原生 ESM 命名导出 |
|---|---|---|---|
module.exports = function | 否 | 该函数 | 通常扫描不到 |
exports.a = 1; exports.b = 2 | 否 | exports 对象 | a、b 可用 |
exports.default = fn; exports.a = 1 | 是 | fn | a 可用 |
module.exports = createDynamicApi() | 否 | 动态对象 | 很可能扫描不到 |
module.exports = { a: 1 } | 否 | 该对象 | 通常扫描不到 a,视为普通默认导出 |
这张表在实际业务里非常有用:如果你负责维护一个要同时被 CJS 和 ESM 消费的底层包,想让命名导出可控,就把导出写成exports.name = ...这种静态模式,而不是module.exports = {}一把梭。
3. TypeScript 编译视角:esModuleInterop 到底改了什么
3.1 开关前后的转译产物差异
esModuleInterop是 TypeScript 用来弥合 CJS 和 ESM default 语义的开关。它影响的不只是类型检查,更重要的是代码生成。
开启前,TS 对 default import 的理解比较“纯洁”,认为 default 就是module.exports上的.default属性;开启后,TS 会生成一层辅助函数,在运行时判断模块有没有__esModule,从而智能决定 default 应该取模块本身还是取.default。
看实际编译产物就明白了。下面是一段源码:
import greet from "./greet-cjs.js"; greet();关闭esModuleInterop时,如果greet-cjs.js被解析成一个export =的 CJS 模块,TS 会在类型层面直接拒绝这段代码;即便通过allowSyntheticDefaultImports强行放行,生成的代码依然没有兜底逻辑,访问的是require("./greet-cjs.js").default,而底层模块的module.exports上如果不存在.default,运行时就崩了。
开启esModuleInterop后,编译产物变成这样:
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const greet_cjs_1 = __importDefault(require("./greet-cjs.js")); (0, greet_cjs_1.default)();注意这个辅助函数:如果模块__esModule为真,说明它是一个“转译后的 ESM 模块”,直接返回原模块,.default就是原本的默认导出;如果没有__esModule,就把它包装成{ default: mod },这样.default拿到的就是整个module.exports。
3.2__importDefault与__importStar的边界
与__importDefault配套的还有__importStar,处理import * as ns的情况:
var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) result[k] = mod[k]; result["default"] = mod; return result; };它的逻辑和 default helper 一脉相承:有__esModule就原样返回;没有的话,把模块的可枚举属性逐个拷贝到新对象里,并额外塞一个default指向模块本体。
理解这个 helper 很重要,因为它是很多线上问题的分水岭。如果开启esModuleInterop之后代码还是出问题,要么是模块的__esModule标记和自己真正的导出形状对不上,要么是打包器预处理时把标记弄丢了。排查思路就是去看编译产物里 helper 走了哪个分支。
3.3 allowSyntheticDefaultImports:类型放行了,运行时不一定接得住
allowSyntheticDefaultImports是另一个容易被误解的选项。它只做一件事:允许类型检查器接受一个原本没有默认导出的 CJS 模块的 default import。它不改变任何代码生成。
于是最坑的配置组合出现了:
{ "compilerOptions": { "module": "commonjs", "moduleResolution": "node10", "target": "es2020", "allowSyntheticDefaultImports": true, "esModuleInterop": false } }在这种组合下,import fs from "fs"可以通过类型检查,因为allowSyntheticDefaultImports给@types/node里的export = fs合成了一个默认导出。但编译生成的代码依然直接访问require("fs").default,而 Node 内置模块并不存在.default属性,运行时就报fs_1.default is undefined或者TypeError: xxx is not a function。
所以记住一个原则:allowSyntheticDefaultImports是本“假证”,只骗 TypeScript 的类型检查器,不骗运行时。真正要改变运行行为的是esModuleInterop。两者经常一起出现,是因为esModuleInterop会隐式开启allowSyntheticDefaultImports,但反过来并不是。
顺带一提,面试里如果被问到“esModuleInterop 是干什么的”,最简练的答法是:它同时做了三件事——启用allowSyntheticDefaultImports、允许import x from "cjs-module"在类型层成立、并在编译产物里生成__importDefault/__importStar辅助函数来对齐运行时行为。能把这三层拆开说清楚,基本就能过关。
4. moduleResolution 从 node10 到 nodenext:类型系统差异的真正引爆点
4.1 node10 和 nodenext 解析的是两套世界
老一代 TypeScript 配置里最常见的moduleResolution: "node",在 TS 5.0 中被改名为node10并标记弃用。它模拟的是 Node 老版本 CJS 的路径解析:逐级查找node_modules、读取 package.json 的main字段、找目录下的index文件。它不认识exports字段,也不理解import和require的条件入口。
而nodenext会完整模拟现代 Node 的解析规则:优先看 package.json 里的exports字段,根据导入方式是import还是require选择不同的入口,还尊重type字段判断文件是 ESM 还是 CJS。两套解析逻辑获得的模块类型可能完全不同。
举个例子。某个包的 package.json 写成这样:
{ "name": "is-even", "type": "module", "exports": { ".": { "types": "./dist/index.d.mts", "import": "./dist/index.mjs", "require": "./dist/index.cjs" } } }在node10下,TS 根本不会看exports,它只会顺着老的main字段去找入口。如果这个包没有main,TS 很可能报“找不到模块声明文件”;即便有,找到的也可能是过时或类型不匹配的声明。切到nodenext后,TS 才能按照被解析的模块的实际类型给出准确类型。
这也是为什么同样的代码,在moduleResolution升级后类型突然变了:以前常用的包在 node10 下用 main/index 解析出的.d.ts格式,和 nodenext 下通过 exports 解析出的可能不是同一个构建产物,类型自然对不上。
4.2 nodenext 为什么隐式开启 esModuleInterop
在实际迁移中你会发现,只要把module设成nodenext,即使 tsconfig 里不写esModuleInterop,编译器也会按esModuleInterop: true来工作。
原因很简单:nodenext要模拟的是原生 Node ESM/CJS 互操作语义,而 Node 本身就把 CJS 的module.exports作为 default 暴露给 ESM。如果 TS 在这种模式下还坚持“没有__esModule就没有 default”,那类型检查的结果就会和真实运行结果大面积冲突。所以 TS 干脆把这条规则默认对齐 Node——这也是“类型系统差异”在模块解析升级后最容易被感知到的地方。
4.3 TS 7.0 移除 node10:迁移前要做的核对清单
热搜词里“选项‘moduleResolution=node10’已弃用,并将停止在 TypeScript 7.0 中运行”说的正是这条链路。TS 5.0 引入了node10这个名字作为旧node的替代,同时开始弃用警告;按计划到 TS 7.0,这个解析模式会被彻底移除。
具体迁移可以按这个顺序走:
- 把
module改成"nodenext",moduleResolution同时改成"nodenext"。两者必须配套,否则 TS 会报配置冲突。 - 逐层检查 package.json 的
type字段。type: "module"意味着项目里的.ts文件会被当作 ESM,.cts才是 CJS;type: "commonjs"或缺失时相反。含糊会让很多文件被重新解释。 - 把所有扩展名写全。
nodenext下 ESM 文件导入相对路径必须带.js扩展名,TS 会据此去匹配.ts源文件。 - 检查
export =用法。原生 ESM 环境不支持export =,TS 在nodenext下遇到这类写法会直接报 TS1203,需要改成 ESM 风格。 - 看
allowSyntheticDefaultImports是否被单独开着。如果esModuleInterop没有显式打开,建议把这两个开关统一收拢,避免歧义。
4.4 baseUrl 弃用与 paths 的替代写法
与moduleResolution: node10同时期被弃用的还有baseUrl。TS 6.0 开始标记弃用,7.0 计划停止运行。很多人之前写路径别名时必须依赖baseUrl,比如:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@common/*": ["src/common/*"] } } }现在不需要baseUrl了。paths本身就支持相对路径,相对基准是 tsconfig.json 所在目录。上面的配置可以改成:
{ "compilerOptions": { "paths": { "@common/*": ["./src/common/*"] } } }关键的坑在于:如果你之前依赖baseUrl: "."把src/common/*解析成项目根下的路径,删除baseUrl后必须把paths里的目标改成./src/common/*,否则路径基准一变,别名全部失效。迁移时最容易出现“tsc 报一堆无法解析的模块”,其实不是 imports 写错了,而是 paths 的基准悄悄变了。
5. 一次完整的 default interop 排查实录
5.1 症状:编译通过,运行报错
假设有一个老项目,tsconfig 是这组配置:
{ "compilerOptions": { "module": "commonjs", "moduleResolution": "node10", "target": "es2020", "allowSyntheticDefaultImports": true, "esModuleInterop": false } }代码里有一行很普通的导入:
import fs from "fs"; fs.readFileSync("./package.json", "utf-8");tsc --noEmit通过,构建也通过。放到 Node 里运行时,却在调用fs.readFileSync时报出TypeError: fs_1.default.readFileSync is not a function。
这里第一反应很容易是“我写错 API 了”,但实际变量打印出来,会发现fs本身是好端端的 fs 模块,但代码访问的是fs.default。
5.2 沿着编译产物找根因
把编译后的 JS 翻出来看,核心逻辑长这样:
const fs_1 = require("fs"); fs_1.default.readFileSync("./package.json", "utf-8");问题就很清楚了:require("fs")返回的是 Node 内置模块对象,对象上没有default属性。为什么 TS 会生成访问.default的代码?因为源码里写了 default import,而allowSyntheticDefaultImports只让类型检查通过了,并没有改变生成逻辑,代码生成还是按“default 就是.default属性”的方式往下走。
然后看另一个对照:如果同样这段代码放在esModuleInterop: true下,编译产物会先包一层__importDefault:
const fs_1 = __importDefault(require("fs"));__importDefault发现fs没有__esModule,于是返回{ default: fsModule }。之后访问fs_1.default,拿到的就是整个 fs 模块,调用自然成功。
所以这个报错不是 fs 的问题,也不是 readFileSync 的写法问题,而是 default interop 策略在类型系统和运行时之间断层导致的。
5.3 修复与验证
正确的修法是把 tsconfig 统一成自洽的组合。推荐的最小改动是:
{ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext", "target": "es2022", "esModuleInterop": true } }nodenext下esModuleInterop隐式生效,allowSyntheticDefaultImports也不必再单独写。如果暂时不想做全量 ESM 迁移,至少要做到下面任意一条:
- 打开
esModuleInterop: true,让编译产物带__importDefault辅助函数; - 或者在源码里改成
import fs from "fs"以外的写法,例如 CJS target 下用import fs = require("fs"); - 在原生 ESM 环境里,用
import * as fs from "fs"也能绕开对.default的依赖。
验证方式也有讲究。tsc --noEmit只能证明类型维度 OK,不能证明运行时行为 OK。最好补一个最小运行用例:在项目里写一个.mjs文件,直接import fs from "fs"跑一次,确认 Node 原生环境下 default 的值;再用编译后的产物跑一遍,对比两次结果。如果两者不一致,基本可以确定是转译层 interop 的问题,而不是业务逻辑的问题。
5.4 这类问题常见的三种变体
排查多了会发现,default interop 相关报错都长得很像:
| 报错现象 | 常见根因 |
|---|---|
Named export 'xxx' not found | CJS 模块的导出写法不被cjs-module-lexer识别,命名导出不可用 |
xxx_1.default is not a function | 编译产物访问.default,但运行时模块没有__esModule也没有.default |
Module has no default export | 类型系统不承认该 CJS 模块有默认导出,常见于export =的.d.ts且未开启 interop |
每种报错对应的排查入口不一样:第一种看 Node 版本和模块导出源码;第二种直接看编译产物;第三种看 tsconfig 和.d.ts声明。先把报错归类到“运行时问题”还是“类型问题”,再动手,效率会高很多。
6. 配置 default interop 时的实操经验
6.1 新项目直接上 nodenext
如果是新项目,我个人的建议是别再用module: commonjs+moduleResolution: node10这种老组合了,直接module: nodenext、moduleResolution: nodenext,package.json 里确认好type字段。虽然前期要习惯写.js扩展名、每层 package.json 都要明确type,但换来的是类型解析和 Node 实际加载行为基本一致,不会再出现“类型说没问题、运行就崩”的两张皮。
6.2 维护老项目时,先分清两个开关的作用域
老项目升级时不必一步到位。可以先只做最小止血:把esModuleInterop打开,把多余的allowSyntheticDefaultImports去掉,保持commonjs和node10继续跑。这一步能解决大部分.default is not a function的问题。
然后再规划moduleResolution升级。升级时不要只看 tsconfig,要把每个子包的 package.json 都过一遍:main、module、exports、types、type字段之间是否自洽。实际迁移里最常见的失败原因,不是 TS 报错本身,而是某个依赖的exports里没有types条件,导致 TS 解析不到正确声明,只能看到运行时入口。
6.3 把“编译产物”当第一证据
最后分享一个排查习惯:遇到任何“编译过、运行挂”的互操问题,不要猜配置,直接把编译后的 JS 打开,看 default import 被编译成了什么形态。
- 如果看到
require("x").default,说明你的代码生成在按“模块有.default属性”的假设运行,此时要核实模块有没有__esModule; - 如果看到
__importDefault(require("x")),说明 helper 已经在兜底,问题大概率出在 helper 对__esModule的判断上; - 如果看到
import x from "x"原样保留,说明文件被当成了原生 ESM,此时跑的就是 Node 自身的 default interop 规则,和 TS 关系不大。
编译产物是三层逻辑(Node 运行时、TS 编译、类型系统)最终的汇聚点,绝大多数谜题都能在这一层找到答案。理解透了这套 default interop 策略,再回头处理moduleResolution升级、包依赖互操、面试题里的 esModuleInterop 问题,都会顺手很多。