《TypeScript编程入门》11.2 ESM/CJS 互操作与 moduleResolution

现实项目里 ES 模块与 CommonJS 长期并存,本节讲清两者如何互相调用。先看 package.json 的 type 字段与 .mjs/.cjs 扩展名如何决定文件的模块类型,再拆解 esModuleInterop、verbatimModuleSyntax 等开关到底改变了什么,以及 import type、__dirname 缺失、双包危害等实战坑与对应报错。

本节目标:当你的代码是 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);
}

表面差异只是关键字,底层的差异却是结构性的:

维度CommonJSES 模块
解析时机运行时,require 是普通函数编译期,静态分析
导出形式单个 module.exports 对象一组具名绑定 + 一个默认导出
加载方式同步异步(Node 里也基本是同步求值,但语义上允许异步)
顶层 thismodule.exportsundefined
变量提升无导入绑定提升
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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes