← 返回题目列表

Node.js 中 CommonJS 和 ES Module 如何选择?

高频 中等 第 6 / 27 题 更新于 2026/07/27
Node.jsCommonJSES Module模块化

简化版

CommonJS 使用 require/module.exports,历史悠久、生态兼容好;ES Module 使用 import/export,是标准模块系统,支持静态分析。Node 通过 package.jsontype 字段、文件扩展名 .mjs/.cjs 区分模块类型。

详细版

CommonJS:

const fs = require('node:fs');
module.exports = { read };

ESM:

import fs from 'node:fs';
export function read() {}

选择建议:

  • 老项目、老依赖多:CommonJS 更省心。
  • 新项目、前后端统一 ESM:优先 ESM。
  • 库开发:要考虑双格式输出和使用者环境。

ESM 中没有 CommonJS 的 __dirname__filename,需要通过 import.meta.url 转换。

完整版教学

一、Node 的模块历史

Node 早期没有原生 ESM,CommonJS 成为事实标准。大量 npm 包都是 CommonJS。后来 JavaScript 标准引入 ESM,Node 才逐步支持。

所以 Node 现在是两套模块系统并存,不是简单谁取代谁。

二、加载方式差异

CommonJS 的 require 是运行时同步加载,适合 Node 早期服务端环境。ESM 是静态结构,加载和解析模型更接近浏览器标准,也支持 top-level await。

ESM 的静态特征更利于工具分析,但和 CommonJS 互操作时有默认导出、命名导出差异。

三、type 字段和扩展名

package.json 中:

{ "type": "module" }

表示 .js 默认按 ESM 处理。.cjs 强制 CommonJS,.mjs 强制 ESM。没有 type 时,.js 通常按 CommonJS。

四、面试追问与工程落地

常见追问是“ESM 里怎么拿 __dirname”。可以用:

import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

工程里不要在一个项目里随意混用两套模块。混用会带来构建、测试、启动脚本和依赖导入问题。选型后尽量统一。

五、执行模型、缓存与绑定语义

CommonJS 加载模块时执行文件,把最终 module.exports 对象放进模块缓存;同一解析路径后续 require 通常拿到同一对象。ESM 则先完成解析和链接,再执行模块,导入的是导出绑定而非简单复制,因此导出变量后续变化可以被导入方观察。

CommonJS:resolve → 首次执行 → 缓存 module.exports → require 返回对象
ESM:     parse → link 依赖图 → evaluate → import 读取 live binding

假设 A、B、C 都导入同一个初始化开销 20 ms 的模块,正常缓存下不是执行 3 次共 60 ms,而是首次执行一次,后续复用。循环依赖时两种系统都可能暴露“尚未完成初始化”的状态,但表现不同;不要依赖偶然执行顺序,应拆出共同依赖或延迟调用。

维度CommonJSESM
主要语法require/module.exportsimport/export
加载模型同步执行式静态链接,可用动态 import()
导出观察module.exports 对象live binding
顶层 await不支持支持
相对路径常可扩展名探测通常要求写完整扩展名

六、互操作、包导出与版本边界

ESM 导入 CommonJS 时,module.exports 可作为默认导出;Node 对命名导出只做静态模式识别,识别不到或运行时新增的属性不可靠,也不会成为真正的 live binding。CommonJS 可用动态 import() 加载 ESM;较新的 Node 也能在满足同步模块图等条件时用 require() 加载部分 ESM,但库设计不应押注最宽松环境。

现代 Node 在文件型 ESM 中提供 import.meta.dirnameimport.meta.filename;需要兼容旧 Node 时仍可用 fileURLToPath(import.meta.url)。库发布还要用 package.jsonexports 条件明确 import/require 入口,避免同一包被两套入口各实例化一次的双包风险。

{
  "exports": {
    ".": { "import": "./dist/index.js", "require": "./dist/index.cjs" }
  }
}

选型锚点:应用项目先统一一种模块格式;库项目再处理互操作、条件导出和最低 Node 版本,不能只看语法喜好。

七、常见误区与追问

  • 误区:没有 type 字段的所有 .js 永远都是 CommonJS。 显式标记最可靠,现代 Node 对歧义输入还可能检查 ESM 语法;包作者应明确写 type。
  • 误区:ESM 可以稳定地命名导入任意 CommonJS 属性。 命名导出依赖静态识别,默认导入 module.exports 往往更稳。
  • 误区:ESM 里的 import.meta.dirname 在所有 Node 版本都存在。 它是较新能力,兼容旧版本仍需 fileURLToPath 方案。
  • 追问:为什么 ESM 更利于静态分析? import/export 结构在执行前可解析,工具更容易构建依赖图和判断导出。
  • 追问:CommonJS 模块会执行几次? 同一解析结果通常首次加载时执行一次,之后命中 require.cache
  • 追问:双格式库最容易踩什么坑? import 与 require 入口若产生两个实例,单例状态、类身份和缓存可能分裂。
  • 追问:新项目是否必须选择 ESM? 不必须,应结合依赖、测试工具和部署 Node 版本;关键是边界明确且项目内统一。

八、加强记忆

CommonJS 是 Node 老生态,ESM 是 JS 标准未来。看 type.mjs.cjs 判断模块类型;新项目可优先 ESM,老项目重兼容。