一门语言,两套模块系统
JavaScript 最初根本没有模块系统。2009 年,Node 用 CommonJS(require、module.exports)填补了这个空白,很多年里 Node 代码都是这个样子。后来在 2015 年,语言本身有了标准的模块系统 ES Modules(import、export),如今浏览器和 Node 都支持它。
所以在实际项目中,两种你都会见到。下面是用两种方式写的同一个小模块:
同样的函数,装在两个不同的“信封”里。本页接下来要讲的,就是每种信封在什么时候会产生影响,以及你应该选择哪一种。
语法上的区别
日常使用中的区别,一张明信片就能写完:
// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");
module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";
export default something;
export const name = value;
export { name };
require 是一个普通的函数调用。import 是一条语句:它只能出现在模块的顶层,而且路径必须是字符串字面量。这个限制并非随意设定,正是它让 ESM 能做到 CommonJS 做不到的事。
真正的区别:静态与动态
CommonJS 在执行到那一行时才求值 require()。你可以把它放在 if 里,在运行时计算路径,按条件加载模块:
ES Modules 是静态的。引擎会在运行任何代码之前解析所有 import 语句,构建依赖图,并提前解析好一切。这就是为什么路径必须是字面量字符串,以及 import 只能出现在顶层。
好处是:工具无需执行任何代码就能看到整个模块图。打包工具正是靠这一点实现 tree-shaking(剔除未使用的导出),编辑器靠它提供准确的自动补全,浏览器靠它并行获取模块。
如果在 ESM 中确实需要动态加载,就用 import():一种类似函数的表达式,返回一个 Promise:
Node 如何判断文件使用哪种系统
一个 Node 项目中可以同时包含这两类文件。Node 通过两点来判断每个文件使用哪种系统:
- 文件扩展名:
.mjs始终是 ESM,.cjs始终是 CommonJS。 - 最近的
package.json中的"type"字段:"module"表示.js文件是 ESM,"commonjs"(缺省时的默认值)表示它们是 CJS。
// package.json
{
"name": "my-app",
"type": "module"
}
设置了 "type": "module" 后,同一个包里的普通 hello.js 就使用 import/export。在旁边放一个 hello.cjs,那个文件就单独使用 require。项目可以借此逐步迁移,库也可以借此同时发布两种版本。
新手常见的坑:在 ESM 文件中,require 和 module.exports 根本不存在。如果你凭肌肉记忆去用它们,就会得到一个 ReferenceError。
互操作:混用两种系统
你经常需要在 ESM 文件中使用 CommonJS 包,或者反过来。这两个方向的规则并不对称。
ESM 导入 CommonJS 可以直接进行。CJS 的 module.exports 对象会成为默认导出:
// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));
从 CommonJS 进行具名导入有时可行(Node 会尝试静态检测具名导出),但为了稳妥起见,先拿到默认导出再解构:
import pkg from "./utils.cjs";
const { parse, stringify } = pkg;
CommonJS 导入 ESM 则是麻烦的方向。你不能 require() 一个 ES 模块,否则会抛出 ERR_REQUIRE_ESM。解决办法是动态 import(),它在 CJS 中同样可用,并返回一个 Promise:
较新的 Node(22 及以上)在特定条件下支持用同步的 require() 加载 ESM,但动态 import() 才是通用的方案。
其他值得了解的行为差异
除了语法,两套系统在一些细节上也不一致,偶尔会让你踩坑:
还有几点:
- 顶层的
this。 在 CJS 中,this是module.exports。在 ESM 中,它是undefined。ESM 始终处于严格模式。 __dirname和__filename。 CJS 直接提供这两个变量。在 ESM 中,你需要从import.meta.url推导出来:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- 导入时的文件扩展名。 对于相对路径,ESM 要求写上扩展名(
"./utils.js",而不是"./utils")。CJS 则比较宽松。 - 实时绑定与快照。 ESM 的导入是对导出模块中变量的实时引用。CJS 给你的是加载时赋给
module.exports的值的副本。大多数代码察觉不到差别,但在循环依赖中就很重要了。
应该用哪一种?
新项目:ES Modules。在 package.json 中设置 "type": "module",然后不必回头。ESM 是语言标准,在浏览器和 Node 中的工作方式一致,支持顶层 await,工具也都以它为设计前提。
以下情况可以继续使用 CommonJS:
- 你在维护一个现有的 CJS 代码库,暂时还不值得迁移。
- 你发布的库需要支持非常老的 Node 版本,或者使用者无法使用 ESM。
- 某个关键依赖只提供 CJS 版本,而且它的互操作很混乱。(现在很少见,但仍会发生。)
即便如此,你也会经常阅读 ESM 代码:近几年发布到 npm 上的包都在朝这个方向发展。两种都熟悉并不是可选项,而对你实际在写的那一种,要熟练掌握它的惯用写法。
快速自查清单
打开一个新文件时,问问自己:
- 这个文件用的是
import/export还是require/module.exports?不要混用。 - 最近的
package.json里"type"是怎么写的? - 如果你要导入一个包,查看它的
package.json:它提供的是 ESM、CJS 还是两者都有? - 如果遇到
ERR_REQUIRE_ESM,说明你在 CJS 中试图加载 ESM。改用动态import(),或者把调用方改成 ESM。
Node 中九成的模块困惑,都属于这四种情况之一。
下一步:npm 基础
模块让你把自己的代码拆分到多个文件中。下一步是引入别人写的代码,这正是 npm 的用途。我们会讲到安装包、semver 版本范围,以及日常真正会用到的 npm 工作流程。
常见问题
JavaScript 中 require 和 import 有什么区别?
require 是 CommonJS 加载模块的方式:它是同步的,在被调用的那一行执行,返回模块赋给 module.exports 的值。import 是 ES Modules 的语法:它是静态的,会被提升到文件顶部,在代码运行之前就完成分析。两者在 this 的值、循环依赖的解析方式以及是否允许顶层 await 上也各不相同。
新的 Node 项目应该用 CommonJS 还是 ES Modules?
用 ES Modules。在 package.json 中设置 "type": "module",然后用 import/export 编写代码。ESM 是官方标准,在浏览器和 Node 中都能使用,还支持顶层 await。CommonJS 仍然出现在较老的包和工具中,所以即使你不写它,也会经常读到它。
同一个项目里可以混用 require 和 import 吗?
可以,但有规则。.mjs 文件或设置了 "type": "module" 的包使用 ESM;.cjs 或 "type": "commonjs" 使用 CJS。ESM 可以 import 一个 CommonJS 模块(module.exports 会作为默认导出)。CommonJS 不能直接 require() 一个 ESM 模块,必须使用动态 import(),它返回一个 Promise。