JavaScript 模块:ESM 与 CommonJS
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
把所有代码塞进一个文件撑不了多久。模块解决三件事:作用域隔离、显式依赖、可复用。这一篇讲现在该用的 ESM,以及仍需读懂的 CommonJS。
环境准备
示例在 Node.js 22 LTS 下验证。想要模块生效,package.json 里需要 "type": "module"(或者把文件后缀写成 .mjs)。浏览器端用 <script type="module"> 加载。
{
"name": "demo",
"type": "module"
}
ESM:具名导出
一个模块可以有多个具名导出,导入时必须用同名标识符(或重命名):
// math.js
export const PI = 3.14159;
export function add(a, b) {
return a + b;
}
export class Counter {
#n = 0;
inc() {
return ++this.#n;
}
}
// main.js
import { add, Counter, PI as pi } from "./math.js";
console.log(add(1, 2), pi);
注意三点:
- 后缀
.js不能省(Node 的 ESM 解析不做后缀补全,浏览器同理)。 import会被提升,写在文件任意位置效果都一样,习惯上放顶部。- 导入的是绑定而不是拷贝:模块内导出的
let变了,导入方看到的值也会变。
ESM:默认导出
// logger.js
export default function log(msg) {
console.log(`[log] ${msg}`);
}
import log from "./logger.js";
默认导出的名字由导入方决定,所以同一模块在不同文件里可能叫不同名字,搜索时不好找。一个模块只给一个主要导出、其余用具名导出是常见折中;纯工具函数集合则全用具名导出。
混合导出也合法,但容易让人困惑:
export default function format() {}
export const VERSION = "1.0.0";
import format, { VERSION } from "./format.js";
重新导出与聚合
// index.js 作为目录入口
export { add } from "./math.js";
export * from "./strings.js";
export { default as log } from "./logger.js";
export * 不转发默认导出,这点经常踩。
动态 import
静态 import 只能在顶层,且会在加载时就把依赖拉下来。需要按需加载(大组件、可选功能、按环境分支)时用 import():
async function loadChart() {
const { Chart } = await import("./chart.js");
return new Chart();
}
import() 返回 Promise,天然可以配合 try/catch 做降级。打包工具(Vite、webpack)会据此做代码分割。
CommonJS:老生态的写法
Node 早期只有 CJS,至今大量包仍是这个形态:
// 导出
const { readFile } = require("node:fs/promises");
function parse(text) {
return JSON.parse(text);
}
module.exports = { parse };
// 或只导出一个函数:module.exports = parse;
// 导入
const { parse } = require("./parse");
const parse2 = require("./parse"); // 若上面是 module.exports = parse
关键差异:
| 维度 | ESM | CJS |
|---|---|---|
| 语法 | import / export |
require / module.exports |
| 加载时机 | 静态解析,编译期确定 | 运行时执行 |
| 导出内容 | 实时绑定 | 值快照(导出后改 exports.x 才生效) |
顶层 await |
支持 | 不支持 |
| 严格模式 | 默认严格 | 需手动 "use strict" |
| 文件后缀 | .mjs 或 type: module |
.cjs 或默认 |
CJS 的「值快照」有个经典后果:
// counter.cjs
let n = 0;
module.exports = { n, inc: () => n++ };
// 使用方拿到的 n 永远是 0
const c = require("./counter.cjs");
c.inc();
console.log(c.n); // 0
想在 CJS 里共享可变状态,就导出一个对象再改它的属性。
互操作规则
- ESM 可以导入 CJS:
import pkg from "cjs-pkg"拿到module.exports;具名导入能否工作取决于打包器/Node 的静态分析,不可靠时用默认导入再解构。 - CJS 导入 ESM:Node 22.12 起
require()可以直接加载同步的 ESM,但含顶层await的模块仍不行,需改用await import()。 - 同一个包被两种方式加载,可能出现两份实例(模块状态不共享),这是「配置改了不生效」类问题的常见来源。
// CJS 中加载 ESM 的最稳写法
const mod = await import("./esm-only.mjs");
package.json 的 exports
库作者用它控制外部能导入哪些路径,也决定 ESM/CJS 双格式分发:
{
"name": "my-lib",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs",
"types": "./dist/index.d.ts"
},
"./utils": "./dist/utils.js"
}
}
配了 exports 之后,没列出的路径就导不进去(my-lib/dist/内部文件.js 会失败)。这是有意的封装,但升级依赖时可能突然报「找不到模块」。
循环依赖
两个模块互相 import 时,ESM 会给导入方一个「尚未初始化」的绑定,访问就是 undefined 或 TDZ 报错:
// a.js
import { b } from "./b.js";
export const a = "A";
console.log(b);
// b.js
import { a } from "./a.js";
export const b = "B";
console.log(a);
运行时可能打印 undefined(取决于谁先被求值)。解法不是加 setTimeout,而是抽出第三个模块放共享部分,或改成函数内延迟调用 import()。
常见错误与注意事项
1. 在 ESM 里用 require / __dirname
// 报错:require is not defined
const path = require("node:path");
改用:
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2. 忘写扩展名
import { add } from "./math" 在打包器里能过,直接用 Node 跑就失败。统一写全后缀,行为最一致。
3. 只改导出不重启
Node 没有热更新;改了被导入的模块,得重启进程。用 node --watch app.js 省事。
4. 混用两种格式的文件
一个目录里既有 type: module 又有 CJS 依赖时,用后缀明确区分:.mjs 一定是 ESM,.cjs 一定是 CJS。
5. 在浏览器里直接 import "lodash"
裸模块名需要 import map 或打包器解析,浏览器只认相对/绝对 URL。
小结
- 新代码优先 ESM:静态依赖、实时绑定、支持顶层
await。 - 具名导出优于默认导出(便于重命名与静态分析);一个模块一个默认导出是可行的折中。
import()用于按需加载,返回 Promise,可try/catch。- CJS 的导出是值快照,互操作时注意双实例问题。
- 循环依赖靠抽公共模块解决,不要靠时序补救。