返回首页

CommonJS 与 ES Module:Node.js 两套模块系统的恩怨

require 和 import 到底差在哪?同步 vs 异步、运行时 vs 静态、循环依赖、互操作,一篇讲清两套模块系统的取舍。

46 约 4 分钟 · 4008 字 后端

写 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.dirnameimport.meta.filename,可以直接用,省去上面那段样板代码。如果你的运行环境够新,优先用它。

怎么告诉 Node 用哪套规则

Node 判断一个文件是 CJS 还是 ESM,看两件事:

  1. 文件扩展名:.mjs 一定是 ESM,.cjs 一定是 CJS
  2. .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 强制指定,.jspackage.json"type"
  • ESM 里没有 __dirname,用 import.meta.url 或新版的 import.meta.dirname
  • ESM 能 import CJS;CJS 加载 ESM 用 await import()
  • 新项目用 ESM,发库用 exports 双导出兼容两边

搞清楚这两套系统的边界,那些 require is not defined 的报错,你一眼就能看出是哪里的 type 没配对。