JavaScript 模块:ESM 与 CommonJS

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Node.js 22 LTS

把所有代码塞进一个文件撑不了多久。模块解决三件事:作用域隔离、显式依赖、可复用。这一篇讲现在该用的 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 的导出是值快照,互操作时注意双实例问题。
  • 循环依赖靠抽公共模块解决,不要靠时序补救。

下一步