要点
ESM 的 import 与 CommonJS 的 require 有不同解析和加载规则。扩展名、package type 与工具链共同决定文件怎样被解释。
两套模块体系
CommonJS(历史体系)
Node 早期方案,同步加载:
// cjs/add.cjs
function add(a, b) { return a + b; }
module.exports = { add };
// cjs/main.cjs
const { add } = require("./add.cjs");
console.log(add(1, 2));
ESM(现行标准)
JavaScript 官方标准,静态结构、异步加载、支持顶层 await:
// esm/add.js
export function add(a, b) { return a + b; }
export default add;
// esm/main.js
import add, { add as plus } from "./add.js";
console.log(plus(1, 2));
关键差异速览
| 维度 | ESM | CommonJS |
|---|---|---|
| 加载时机 | 编译期静态分析(可 tree-shaking) | 运行时动态 require |
| 导出绑定 | 实时绑定(值变化可见) | module.exports 对象的共享引用;重新赋值不会反向更新已取出的局部变量 |
| 顶层 await | ✅ 支持 | ❌ 不支持 |
__dirname | ❌ 需从 import.meta.url 推导 | ✅ 全局可用 |
| 条件加载 | 需用动态 import() | 可条件 require |
| 严格模式 | 始终启用 | 默认非严格 |
互操作规则(最常踩坑处)
// ESM 中导入 CJS 包:default 即 module.exports
import _ from "lodash"; // ✅ CJS 包的整组导出挂在 default 上
import { map } from "lodash"; // ⚠️ 取决于该包是否提供命名导出(由 cjs-module-lexer 推断)
// CJS 中加载 ESM:只能动态 import
// old-plugin.cjs
async function load() {
const { modern } = await import("./modern.mjs");
}
实践:type 字段与文件扩展名
Node 判断一段 .js 代码是 ESM 还是 CJS,依据如下:
| 条件 | 解析为 |
|---|---|
最近 package.json 有 "type": "module" | ESM |
最近 package.json 有 "type": "commonjs"(或无 type) | CJS |
扩展名 .mjs | 永远 ESM |
扩展名 .cjs | 永远 CJS |
# 验证实验
mkdir module-lab && cd module-lab
pnpm init && pnpm pkg set type=module
echo 'export const who = "esm";' > lib.js
echo 'import { who } from "./lib.js"; console.log(who);' > main.js
node main.js # 输出 esm
验证方法: 把 type 改回 commonjs 再运行,观察报错——这是理解解析规则的最好方式。
实践:exports 字段设计公共 API
exports 是现代包定义入口的标准方式,同时起到”封箱”作用——未列出的路径外部无法导入。
{
"name": "@quest/utils",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
},
"./cli": "./dist/cli.js",
"./internal/*": null
}
}
条件键说明:
types:TypeScript 消费者定位类型声明import:ESM 消费者使用require:CJS 消费者使用- 匹配顺序先到先得:
node-addons→node/import/require→default "./internal/*": null:显式禁止外部深层导入
内部子路径别名用 imports 字段(以 # 开头):
{
"imports": {
"#config": "./src/config.js",
"#lib/*.js": "./src/lib/*.js"
}
}
// 任何位置都能稳定导入,不受相对路径层级影响
import { config } from "#config";
模块解析:从标识符到文件
import x from "y" 时 Node 按以下顺序查找:
- 内置模块:
node:fs、path等直接命中(建议始终加node:前缀) - 相对/绝对路径:
./lib.js——ESM 必须写全扩展名(不像 CJS 可省略) - 裸说明符(bare specifier):如
hono,沿当前目录逐级向上找node_modules/<pkg>,读取其exports字段定位入口
目录索引行为差异:
import { x } from "./utils/index.js"; // ✅ 必须写全
import { x } from "./utils"; // ❌ ESM 不再自动补 index.js
TypeScript 项目用 .js 后缀指向 .ts 源文件(编译期映射),或直接用 # 别名避开烦恼。
最佳实践
模块格式按运行环境和依赖契约选择,ESM 与 CommonJS 互操作仍有边界。原生 Node ESM 的相对导入通常需要明确扩展名,TypeScript 构建工具又可能有不同解析规则,需区分源码与运行产物。
exports 可限定包的公共入口,但仅在承诺支持两种消费方式时才需要相应双格式设计。ESM 中没有传统的 require/__dirname 绑定,使用适用版本的 import.meta 能力或标准桥接方式。编写一个真实消费者验证公开入口,避免只在库自己的目录里能导入。
常见问题
Q1: ERR_MODULE_NOT_FOUND 但文件明明存在?
A: 九成是缺扩展名。ESM 要求 ./utils.js 而非 ./utils;TypeScript 源码中写 ./utils.js(指向编译目标)或 .ts(Node 原生运行时)。
Q2: require is not defined in ES module scope?
A: 当前是 ESM 上下文。用 await import("...") 或 import { createRequire } from "node:module"; const require = createRequire(import.meta.url); 桥接。
练习与实践
正文验证:ESM 的绑定和扩展名
下面程序是本页“ESM 导出是实时绑定、相对导入要带扩展名”的可独立运行版本。它只使用 Node 标准库;验证脚本会直接从这个 Markdown 代码块提取内容后执行。
import assert from 'node:assert/strict';
const counter = { value: 0 };
const read = () => counter.value;
counter.value += 1;
assert.equal(read(), 1);
assert.equal(new URL('./add.js', import.meta.url).pathname.endsWith('/add.js'), true);
console.log('esm-binding: live read and explicit extension');
练习一:解析规则实验
任务要求: 1. 按”type 字段验证实验”复现两种报错并解释原因 2. 把一个 .js 改名为 .mjs,观察在 type: commonjs 项目中仍可 ESM 运行
练习二:发布一个双格式迷你包
挑战任务: – 用 tsc 分别构建 ESM 与 CJS 两个产物 – 写出含 types/import/require 条件的 exports 字段 – 在另一个项目中分别以两种方式导入验证
提示: tsc 单次构建只能输出一种模块格式,需要两次构建或用 tsup 等打包工具。