写 Node.js 总会撞上这类报错:require is not defined in ES module scope,或者 Cannot use import statement outside a module。它们的根源都是一件事:Node.js 里同时存在两套模块系统,而它们的规则不一样。
这篇把 CommonJS(CJS)和 ES Module(ESM)的区别讲清楚,以及在 2026 年的今天该怎么选。
两种写法的直观对比
CommonJS,Node.js 诞生之初就有的格式:
// math.cjs
function add(a, b) {
return a + b
}
module.exports = { add }
// main.cjs
const { add } = require('./math.cjs')
console.log(add(1, 2))ES Module,JavaScript 语言层面的官方标准:
// math.mjs
export function add(a, b) {
return a + b
}
// main.mjs
import { add } from './math.mjs'
console.log(add(1, 2))写法只是表象,底层机制的差异才是关键。
核心区别:同步运行时 vs 异步静态
CommonJS 的 require 是同步且运行时执行的。当代码跑到 require('./x') 这一行,才会去加载并执行那个文件,加载完整个返回 module.exports。因为是普通函数调用,你甚至可以写在 if 里、动态拼路径:
// CJS 里完全合法:条件加载、动态路径
if (process.env.NODE_ENV === 'development') {
const devTool = require('./dev-tool')
}
const mod = require(`./plugins/${name}`)ES Module 则是静态的。import 必须写在模块顶层,在代码执行之前,JS 引擎就先把所有 import/export 关系分析出来(这一步叫"链接")。正因为这种静态结构,才有了 Tree Shaking(摇掉没用到的导出)这类优化:
// ESM:这样写直接报错,import 不能放进 if
if (cond) {
import './x.mjs' // SyntaxError
}ESM 要动态加载得用 import() 函数(返回 Promise):
if (cond) {
const mod = await import('./x.mjs') // OK,这是动态导入
}一个容易翻车的细节:live binding
CJS 的 require 拿到的是值的拷贝(快照);ESM 的 import 拿到的是实时绑定(live binding)。看这个对比就懂了:
// counter.mjs
export let count = 0
export function inc() { count++ }// ESM 端:能看到 count 的实时变化
import { count, inc } from './counter.mjs'
console.log(count) // 0
inc()
console.log(count) // 1 ✅ 跟着变了换成 CommonJS,const { count } = require(...) 解构出来的 count 是导入那一刻的快照,后续不会变。这个差异在做循环依赖、热更新时会带来完全不同的行为。
ESM 里没有 __dirname 和 __filename
这是迁移到 ESM 时最常见的坑。CJS 里随手就能用的两个变量,在 ESM 里不存在:
// CJS:直接用
const p = path.join(__dirname, 'config.json')ESM 里要靠 import.meta.url 自己算:
import { fileURLToPath } from 'node:url'
import path from 'node:path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)Node 20.11 / 21.2 之后新增了
import.meta.dirname和import.meta.filename,可以直接用,省去上面那段样板代码。如果你的运行环境够新,优先用它。
怎么告诉 Node 用哪套规则
Node 判断一个文件是 CJS 还是 ESM,看两件事:
- 文件扩展名:
.mjs一定是 ESM,.cjs一定是 CJS .js文件看就近的package.json的"type"字段:"type": "module"→.js按 ESM 解析- 没写或
"type": "commonjs"→.js按 CJS 解析
{
"name": "my-app",
"type": "module"
}一旦设了 "type": "module",项目里所有 .js 都变成 ESM,这时还想写 CJS,就把那个文件改成 .cjs。
两套系统怎么互相调用
互操作规则需要记清楚:
- ESM 可以 import CJS:
import _ from 'lodash',CJS 的module.exports会变成默认导出 - CJS 不能直接 require ESM(因为 ESM 是异步加载的,而 require 是同步的),只能用动态
import():
// 在 CJS 文件里加载 ESM
async function load() {
const esmMod = await import('./some-esm-module.mjs')
esmMod.doSomething()
}补充:较新的 Node 版本(22 起)在满足条件时已允许
require()加载没有顶层 await 的 ESM 模块,这个限制正在被逐步放开。但跨版本兼容时,用await import()仍是最稳的写法。
用 exports 字段同时支持两套
如果你在发布一个 npm 包,想同时让 CJS 和 ESM 用户都能用,可以在 package.json 里配 exports 条件导出:
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs"
}
}
}这样 import 'my-lib' 走 .mjs,require('my-lib') 走 .cjs,各取所需。
2026 年该选哪个
给一个实用建议:
- 新项目直接上 ESM。它是语言标准,生态早已跟上,顶层
await、Tree Shaking 都是实打实的好处 - 老项目不必硬迁。CJS 仍然被完整支持,稳定运行的代码没必要为了"现代"去冒迁移风险
- 写库优先用
exports双导出,把选择权留给使用者
小结
- CJS 是同步、运行时、值拷贝,可动态 require,胜在灵活;ESM 是异步、静态、live binding,可 Tree Shaking,胜在标准与优化
.mjs/.cjs强制指定,.js看package.json的"type"- ESM 里没有
__dirname,用import.meta.url或新版的import.meta.dirname - ESM 能 import CJS;CJS 加载 ESM 用
await import() - 新项目用 ESM,发库用
exports双导出兼容两边
搞清楚这两套系统的边界,那些 require is not defined 的报错,你一眼就能看出是哪里的 type 没配对。