ES Modules与CommonJS互操作:esModuleInterop、default导入与moduleResolution迁移详解

发布时间:2026/9/25 11:28:30
ES Modules与CommonJS互操作:esModuleInterop、default导入与moduleResolution迁移详解 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: truedefault 导出就不再取module.exports整体而是取module.exports.default。也就是说Node 会“尊重”这个模块原本设计好的 ESM 默认导出而不是强行把整个 exports 对象塞给你。这个细节解释了为什么有的包在两种加载方式下表现一致手写 CJS、没有__esModuledefault 整个module.exports。TS/Babel 转译产物、有__esModuledefault 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是fna 可用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字段去找入口。如果这个包没有mainTS 很可能报“找不到模块声明文件”即便有找到的也可能是过时或类型不匹配的声明。切到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迁移前要做的核对清单热搜词里“选项‘moduleResolutionnode10’已弃用并将停止在 TypeScript 7.0 中运行”说的正是这条链路。TS 5.0 引入了node10这个名字作为旧node的替代同时开始弃用警告按计划到 TS 7.0这个解析模式会被彻底移除。具体迁移可以按这个顺序走把module改成nodenextmoduleResolution同时改成nodenext。两者必须配套否则 TS 会报配置冲突。逐层检查 package.json 的type字段。type: module意味着项目里的.ts文件会被当作 ESM.cts才是 CJStype: 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下编译产物会先包一层__importDefaultconst 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 foundCJS 模块的导出写法不被cjs-module-lexer识别命名导出不可用xxx_1.default is not a function编译产物访问.default但运行时模块没有__esModule也没有.defaultModule has no default export类型系统不承认该 CJS 模块有默认导出常见于export 的.d.ts且未开启 interop每种报错对应的排查入口不一样第一种看 Node 版本和模块导出源码第二种直接看编译产物第三种看 tsconfig 和.d.ts声明。先把报错归类到“运行时问题”还是“类型问题”再动手效率会高很多。6. 配置 default interop 时的实操经验6.1 新项目直接上 nodenext如果是新项目我个人的建议是别再用module: commonjsmoduleResolution: node10这种老组合了直接module: nodenext、moduleResolution: nodenextpackage.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 问题都会顺手很多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询