Node.js 最容易让人反复踩坑的知识点之一,就是模块系统。require 和 import 为什么会同时存在?同样是一个 .js 文件,为什么在不同项目里行为会不同?为什么有时候 __dirname 可用,有时候又突然报错?这些问题看起来分散,背后其实都指向同一件事:你到底处在什么模块系统里。
这一篇我们会把 CommonJS、ESM 和 Node.js 的模块解析规则放到一起理解。目标不是一次性把所有边角细节都背下来,而是先建立一套稳定的模块心智,这样后面不管你写脚本、写服务还是用工具链,都不会在“它为什么这样导入”这类问题上反复卡住。
为什么 Node.js 会同时存在 CommonJS 和 ESM?
如果你只从今天的 JavaScript 语法出发,看到 require 和 import 并存,第一反应往往是“为什么不统一?”这个问题很自然,但理解它需要一点历史视角。
CommonJS 先于原生模块标准
Node.js 诞生得很早,而 JavaScript 官方标准里的模块系统,也就是今天我们熟悉的 import / export,是在后面才逐渐成熟的。在 Node 需要解决“多文件如何组织代码”这个问题时,社区和运行时首先采用的是 CommonJS 方案。
它的特点很鲜明:
- 使用
require()导入模块; - 使用
module.exports或exports暴露能力; - 模块加载是同步的;
- 每个文件是一个独立模块。
在那个阶段,这个方案非常实用。它贴合 Node 当时的运行场景,也足够简单直接,所以长期成为 Node 世界里的主流模块机制。
ESM 是 JavaScript 官方标准
后来,JavaScript 标准本身终于拥有了官方模块系统,也就是 ECMAScript Modules,简称 ESM。它带来了今天我们已经很熟悉的写法:
import { readFile } from 'node:fs/promises'
export function main() {}ESM 的意义,不只是写法更现代,而是它提供了更标准化的模块语义,比如:
- 静态可分析;
- 更利于工具链做优化;
- 在浏览器和服务端之间拥有更统一的语言层体验。
问题也正是从这里开始出现的:Node.js 已经有了庞大的 CommonJS 生态,但语言标准又带来了 ESM。于是,Node 不能简单把旧世界全部推翻,只能同时兼容这两套体系。
也就是说,今天 Node.js 的模块世界之所以显得复杂,并不是因为设计者故意让它复杂,而是因为它必须同时照顾历史生态和标准演进。
CommonJS 和 ESM 到底有什么差别?
很多教程会直接告诉你“require 是旧的,import 是新的”,但这句话过于粗糙。更准确的理解方式是:它们不是同一件事的两套皮肤,而是在加载方式、绑定行为和工具链语义上都存在明显差异的两套模块模型。
CommonJS 的基本模型
CommonJS 最直观的写法是这样:
const { readFileSync } = require('node:fs')
function loadConfig() {
return readFileSync('./config.json', 'utf-8')
}
module.exports = {
loadConfig,
}它的心智可以先简单理解成:
- 导入发生在运行时;
require()本质上是一个函数调用;- 当前模块暴露什么,由
module.exports决定; - Node 会对加载过的模块做缓存。
这种方式的优点是直接、朴素、好理解。对服务端和脚本场景来说,同步加载在很多时候也完全可接受。
但它也有一些局限,比如:
- 静态分析能力较弱;
- 更难和浏览器端原生模块模型保持一致;
- 在某些现代工具链场景下不如
ESM自然。
ESM 的基本模型
ESM 的典型写法则是:
import { readFile } from 'node:fs/promises'
export async function loadConfig() {
return readFile('./config.json', 'utf-8')
}它和 CommonJS 的关键区别,不只是语法从函数变成了关键字,而是模块系统本身的行为也变了。
你可以先抓住几个重点:
import/export是语言层语法,不是普通函数;- 模块依赖关系更容易被静态分析;
- 导入导出绑定更强调“活绑定”而不是简单值拷贝;
- 顶层
await等现代能力也与ESM更自然地结合。
这意味着,ESM 不只是“写起来更像浏览器”,它在语言一致性和工具链协作上也更占优势。
它们最容易被忽视的几个行为差异
在初学阶段,你最需要先建立的不是所有细节,而是下面这几个高频差异:
1. 导入写法不同CommonJS 用 require(),ESM 用 import。
2. 导出写法不同CommonJS 通过 module.exports 控制整体导出对象;ESM 则显式声明导出成员。
3. 某些上下文变量不同比如 __dirname、__filename 在传统 CommonJS 中常见,但在原生 ESM 中不会直接提供。
4. 路径解析细节不同比如在原生 ESM 中,相对路径导入通常要更明确地写出扩展名。
这些差异如果没有提前建立心智,后面会表现为各种“为什么这个项目能跑、另一个项目不行”的现象。
Node.js 如何判断一个文件到底属于哪种模块系统?
真正让很多人困惑的,并不是知道有两套模块系统,而是:Node 到底如何判断当前文件该按哪一套规则来处理?
这也是 Node 模块问题最核心的一层。
文件后缀会直接影响模块类型
Node.js 对几个后缀有明确约定:
.cjs文件会按CommonJS处理;.mjs文件会按ESM处理。
这两个后缀非常直接,几乎没有歧义。所以在需要显式区分模块类型时,它们是最清晰的标记方式。
.js 文件要结合 package.json 来判断
真正让人容易混乱的,是普通的 .js 文件。因为它到底按哪种模块方式处理,不只取决于后缀,还取决于当前包环境里的 package.json 配置。
如果 package.json 里写了:
{
"type": "module"
}那么当前包内的 .js 文件会按 ESM 处理。
如果没有这个字段,或者显式声明为别的值,那么 .js 通常仍然会按 CommonJS 对待。
所以,一个非常实用的经验是:不要只看文件名,要看这个文件所在包的模块语义。
原生 ESM 下相对导入更严格
在 CommonJS 里,很多路径行为看起来比较宽松;但在原生 ESM 中,模块路径必须更明确。最典型的一点就是:相对导入时通常不能含糊其辞,扩展名往往需要写完整。
这背后的本质是,ESM 更强调标准化模块解析,而不是靠 Node 历史行为做尽可能多的推断。
包导入还会受到 exports 等配置影响
除了 type 字段,现代包还可能通过 exports、imports 等字段进一步控制“哪些入口可以被外部导入”。你现在不需要把这些高级配置一次性学完,但要先知道:Node 的模块解析已经不仅仅是“找同名文件”那么简单,它是受包边界约束的。
模块缓存、循环依赖和迁移时最常见的坑
理解模块系统,最后不能只停留在“语法会写”。因为真实项目里的很多问题,恰恰都发生在导入导出边界上。
模块缓存不是偶然现象
无论是 CommonJS 还是 ESM,模块加载后通常都不会每次都重新执行。Node.js 会维护模块级别的缓存和模块图,这样做是为了避免重复执行和重复初始化。
这带来一个重要结果:如果某个模块在加载时就创建了状态、打开了连接、做了副作用操作,那么后面别的地方再导入它,看到的通常是同一个已初始化结果,而不是一份全新的执行环境。
这也是为什么很多基础设施模块会在初始化时格外谨慎。因为模块一旦带副作用,它的导入时机和缓存行为就会直接影响整个应用。
循环依赖为什么麻烦
循环依赖指的是:
- 模块 A 导入模块 B;
- 模块 B 又反过来导入模块 A。
这种情况在小项目里不一定立刻出问题,但随着模块之间的初始化逻辑越来越复杂,它很容易演变成“某个值在使用时还没准备好”的问题。
在 CommonJS 和 ESM 里,循环依赖的表现方式并不完全一样,但有一个共同点:它们都会暴露模块边界设计不清的问题。
所以,真正要解决循环依赖,通常不是“背一个技巧绕过它”,而是回头重新审视模块职责和依赖方向。
双模块共存时不要想当然
今天很多项目处在过渡阶段:有些工具还是 CommonJS,有些代码已经是 ESM。在这种场景下,最容易犯的错误就是把两边行为想象得过于对称。
常见误区包括:
- 以为
default export和module.exports总能无损互转; - 以为一个项目里换掉语法就等于换掉了模块语义;
- 以为工具链总会自动帮你把差异抹平。
更稳妥的思路是:明确当前项目的主模块系统,再有意识地处理跨体系协作,而不是边写边猜。
总结
这一篇我们把 Node.js 的模块世界先整体梳理了一遍:为什么会同时存在 CommonJS 和 ESM,它们各自的基本模型是什么,Node 如何判断当前文件采用哪套规则,以及模块缓存、循环依赖和迁移过程中最常见的坑。
真正重要的不是今天就把所有细节背熟,而是先建立这样一个心智:模块系统不是单纯语法问题,它会直接影响文件上下文、路径解析、导入导出行为和项目协作方式。理解这层以后,后面的工程实践才会稳。
下一篇我们会把视角拉到 Node.js 和操作系统交互的基础层,开始真正接触 process、path、fs 和 url 这些最常用的运行时能力。