Menu

CommonJS 与 ES Modules:require 和 import 的区别

JavaScript 自带的两种模块系统:为什么两者并存,以及在 Node 项目中如何在 require 和 import 之间做选择。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

一门语言,两套模块系统

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。

Coddy 编程语言插图

和 Coddy 一起学编程

开始