news 2026/9/25 11:28:27

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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: 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是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字段去找入口。如果这个包没有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,这个解析模式会被彻底移除。

具体迁移可以按这个顺序走:

  1. 把module改成"nodenext",moduleResolution同时改成"nodenext"。两者必须配套,否则 TS 会报配置冲突。
  2. 逐层检查 package.json 的type字段。type: "module"意味着项目里的.ts文件会被当作 ESM,.cts才是 CJS;type: "commonjs"或缺失时相反。含糊会让很多文件被重新解释。
  3. 把所有扩展名写全。nodenext下 ESM 文件导入相对路径必须带.js扩展名,TS 会据此去匹配.ts源文件。
  4. 检查export =用法。原生 ESM 环境不支持export =,TS 在nodenext下遇到这类写法会直接报 TS1203,需要改成 ESM 风格。
  5. 看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 foundCJS 模块的导出写法不被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 问题,都会顺手很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 11:26:31

迷你SQL 2000:老系统迁移的轻量兼容方案

简介:迷你SQL 2000是一款面向个人用户和小型企业的轻量级数据库管理系统,专为Windows XP/7/10的32位与64位环境设计,在保留SQL Server 2000核心SQL功能的基础上,大幅降低内存和磁盘占用,适用于硬件配置有限、不需要复杂…

作者头像 李华
网站建设 2026/9/25 11:24:17

Atlas 300V 24G上部署YOLOv5:从CANN转换到pyACL推理全攻略

最近一直在折腾一台装了 Atlas 300V 24G 的服务器,连续几个晚上在 C 和 Python 之间来回横跳,才总算把 YOLOv5 跑通,延迟也压到了能看的水平。身边朋友知道我在搞这个东西之后,问最多的两个问题,跟你在搜索框里敲的几乎…

作者头像 李华
网站建设 2026/9/25 11:24:12

多商户系统开发全流程实战指南 核心架构设计与落地避坑经验分享

多商户系统是当前本地生活、电商、家政、外卖等多个领域的主流系统架构,相比单商户系统,它支持多主体入驻、权责分离、资源整合,能够大幅提升平台的运营效率。本文结合外卖、家政、电商、CPS服务等多场景多商户系统的开发实战,从核…

作者头像 李华
网站建设 2026/9/25 11:20:26

Windows下Hadoop连接失败?winutils配置与排错全指南

简介:面向需要在Windows本地连接与调试Hadoop集群的开发者,这份zip包提供了2.6.0至3.0.0各版本对应的winutils与hadoop.dll。在Windows上直接运行或调试Hadoop任务时,常因缺少原生Windows组件而报错,使用本包可快速补齐环境依赖&a…

作者头像 李华
网站建设 2026/9/25 11:16:33

AI编程分享:用TaoToken统一Key接入多重计时器 Android App 的配置骨架

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华