☰
深入理解 CommonJS 与 ES Module:运行机制、互操作与迁移
2026/10/27 6:13:24 网站建设 项目流程

1. 先把话说在前面:这两套模块规范不是二选一的选择题

我刚接手过一个内部维护了好几年的老项目,第一天就卡在一个报错上:Cannot use import statement outside a module。翻代码发现,同一个目录下,有几个文件用require和module.exports,另外几个用import和export,还有两个文件里两者混着写,靠打包工具的转译勉强跑着。改一个文件,另外三个跟着崩。这种体验大概每个维护过中大型项目的人都经历过。

CommonJS 和 ES Module 是 JavaScript 世界里并存时间最久、冲突也最多的两套模块规范。前者是运行时规范的产物,require()是一个实实在在的函数调用,模块在代码真正执行到那一行时才被加载和求值;后者是语言标准的一部分,import和export是关键字,模块依赖关系在代码还没跑起来的时候就已经确定了。这两句话看起来只是措辞差异,实际上决定了后面所有行为差异的根源——缓存、循环依赖、Tree-shaking、互操作规则,全都从这一条推出来。

这篇内容会把两套规范拆到运行时层面讲清楚:require内部到底做了什么、export为什么被称为"活连接"、循环依赖在两边为什么表现完全不同、Node 环境下两套规范互相调用时的规则是什么、package.json里那几个字段的优先级到底怎么排。写这篇文章的初衷,是因为我见过太多人把这两套东西当成"新旧语法"来理解,结果一遇到双包问题、循环依赖、命名导出丢失就抓瞎。适合谁看?写过require或者import但没深究过底层行为的开发者、需要维护历史项目同时又要上新工程的人、以及在配置构建工具时被exports字段绕晕过的人。如果你只是想抄个配置,这篇可能有点啰嗦;但如果你想以后不再被这类报错绊住,往下看。

2. CommonJS 的运行机制:require那一行到底发生了什么

2.1 五步加载流程,每一步都有坑

很多人以为require就是"读文件然后执行",实际上规范里它至少拆成五步,每一步都对应一类常见故障。理解这五步,绝大多数require相关的问题都能自己定位。

第一步是路径解析。require接受的路径会经过一套相当复杂的查找逻辑:相对路径优先补全后缀(.js、.json、.node),再尝试当作目录去找package.json的main字段,最后回落到index.js;非相对路径(比如require('lodash'))则从当前目录一路向上找node_modules,直到文件系统根目录。这套"省略后缀 + 自动找 index"的便利是有代价的:同名文件在不同位置的优先级不同,foo.js和foo/index.js同时存在时,前者永远优先。我踩过一次坑,项目里同时有utils.js和utils/目录,本地跑得好好的,CI 上报错,原因是两个环境里某个文件的创建顺序不同,导致解析到了不同的目标。这种问题排查起来非常痛苦。

第二步是查缓存。require.cache是一个以模块绝对路径为键的对象,命中就直接返回module.exports,不做任何重复执行。这解释了为什么在 CJS 里模块状态是天然单例的——共享一个计数器、共享一个连接池,都是靠这个缓存实现的。但缓存键用的是解析后的真实路径(会处理软链接),所以在 monorepo 或者用npm link做本地调试时,同一个包被解析成两个不同路径,就会出现"两份状态"的诡异现象。这时候需要清理缓存重新加载,或者干脆用--preserve-symlinks之类的参数调整解析行为。

第三步是创建模块对象。系统会生成一个形如{ id, filename, loaded: false, exports: {}, children: [], paths: [] }的对象。

第四步是最关键的包装执行。模块源码并不是直接在全局作用域里跑的,而是被包进一个函数里:

(function (exports, require, module, __filename, __dirname) { // 你的模块代码 });

这行包装解释了 CJS 里几个"凭空出现"的变量从哪来:__filename、__dirname、module、exports、require其实都是函数参数,不是全局变量。也解释了为什么 CJS 模块顶层this指向module.exports——因为函数是以module.exports为接收者调用的。更重要的一点是,这个包装让每个模块拥有了独立的作用域,不会污染全局,这才是模块化的核心价值。至于第五步,就是执行完毕后把module.loaded置为true,返回module.exports。

注意:包装函数是通过字符串拼接后传给vm之类的手段编译执行的,这意味着调试时你看到的行号可能与源码差一行,在排查某些堆栈信息时需要对一下偏移。

2.2exports和module.exports的那点事,说清楚一次就够

新手最常问的问题:exports和module.exports有什么区别?答案是——在模块开始执行时,exports就是module.exports的一个引用副本,指向同一个对象。

// a.js —— 写法一:挂属性,有效 exports.name = 'demo'; exports.sayHi = () => console.log('hi'); // b.js —— 写法二:整体重赋值,前面的都失效 exports.name = 'demo'; module.exports = { age: 1 }; // 最终导出只有 { age: 1 },name 丢了

原因很直白:module.exports = {...}把module.exports指向了一个新对象,而exports还指着旧的那个。而require返回的永远是module.exports,所以旧对象上的属性自然就消失了。

还有一种更隐蔽的失效场景:先整体赋值,再往exports上挂东西。

module.exports = { a: 1 }; exports.b = 2; // 挂到了旧对象上,白写 console.log(module.exports); // { a: 1 }

我的习惯是:只用一个。要么全程module.exports.xxx = ...,要么只在文件最后做一次module.exports = {...}。混着写的人,早晚会在这上面栽一次。另外提醒一句,module.exports赋值为一个函数或者一个类,是 CJS 导出单例能力的"默认导出"惯用法,这也是后来 ESM 互操作时"默认导出到底取什么"让人困惑的源头之一。

2.3 循环依赖:CJS 返回的是"半成品"

CJS 的循环依赖行为,可以概括成一句话:当 A 依赖 B、B 又依赖 A 时,B 拿到的是 A 尚未执行完的exports对象快照。

// a.js console.log('a 开始'); const b = require('./b'); console.log('b 的导出是', b); module.exports = { name: 'a', done: true }; console.log('a 结束'); // b.js console.log('b 开始'); const a = require('./a'); console.log('a 的导出是', a); module.exports = { name: 'b' }; // main.js require('./a');

实际输出大概是:a 开始 → b 开始 → a 的导出是{}→ b 的导出是{ name: 'b' }→ a 结束。注意 b 里打印的a是空对象,因为此时a.js还没执行到module.exports = ...那一行。如果b.js里写的是a.name,那就直接 TypeError 了。

这类问题最恶心的地方是它不报错——只要你不访问未定义属性,代码就静默地拿着半成品继续跑,跑到某个分支才炸。我的处理原则是:循环依赖本身就是设计问题,优先靠拆公共模块解决;实在拆不掉,就把require挪到函数内部延迟执行,保证真正用的时候对方已经初始化完成。但这只是止血,不是治病。

3. ES Module 的静态本质:为什么它能被"提前读懂"

3.1 三个阶段,决定了它在任何代码执行前就已确定依赖

ESM 的加载过程被拆成三个明确的阶段:构造(Construction)、实例化(Instantiation)、求值(Evaluation)。

构造阶段做的事是:从入口文件出发,把import语句里声明的依赖逐个解析、下载、解析成模块记录,形成一张完整的模块图。这一步不执行任何模块代码。实例化阶段把所有模块的导出和导入连接起来,建立内存地址层面的绑定关系,同样不执行代码。直到求值阶段,模块体才按照依赖图的后序顺序真正跑一遍。

这套设计带来两个直接影响。第一,import必须写在顶层,不能塞进if里或者函数里——因为依赖必须在构造阶段静态确定。第二,正因为依赖图是静态的,工具才有可能在编译期做静态分析:Tree-shaking 删掉没用到的导出、打包器预判加载顺序、类型系统做跨模块检查,全都建立在这个"代码没跑但结构已知"的前提上。

// 这在 ESM 里是不合法的,语法错误 if (process.env.NODE_ENV === 'development') { import { debug } from './debug.js'; } // 需要条件加载,用动态导入 if (process.env.NODE_ENV === 'development') { const { debug } = await import('./debug.js'); }

动态import()返回 Promise,属于表达式而非声明,因此可以在任意位置使用,但它加载的模块同样走完整的构造-实例化-求值流程。顺带一提,动态导入在 CJS 文件里也能用,这是 Node 较新版本里 CJS 里能碰 ESM 的主要通道。

3.2 导出是"活连接",不是值拷贝

这是 ESM 和 CJS 最本质的差异,也是最多人理解不到位的地方。CJS 的module.exports是对象属性的浅拷贝引用——你在 A 里require了 B 的某个变量,拿到的是那个瞬间的值。ESM 不一样,import拿到的是一个指向原变量的绑定(binding),源模块里变量变了,导入方看到的就是新值。

// counter.mjs export let count = 0; export function inc() { count++; } // main.mjs import { count, inc } from './counter.mjs'; console.log(count); // 0 inc(); console.log(count); // 1 —— 这里拿到的是最新值

对比 CJS:

// counter.js let count = 0; module.exports = { get count() { return count; }, // 想要"活"必须自己写 getter inc() { count++; } };

在 CJS 里,如果你写成module.exports = { count, inc },导入方拿到的count永远是 0,不管inc调了多少次。想要活绑定,必须手写 getter。这就是为什么有些库的计数器、状态容器在 CJS 版本里表现怪异,而在 ESM 版本里正常——不是库写错了,是规范本身的差异。

这条规则还有个衍生结论:导入的绑定是只读的。你在导入方写count = 5会直接报语法错误。这从根本上杜绝了"多个模块互相改对方状态"的混乱,但也意味着你不能把导入的变量当成普通变量用。

3.3 提升、暂时性死区,以及那些看起来"反直觉"的顺序

ESM 里import声明会被提升到模块顶部,且导入绑定在实例化阶段就已经建立。但变量本身仍然受 TDZ 约束——函数声明是提升并且可以提前调用的,let/const导出的值在模块求值完成之前访问会抛ReferenceError: Cannot access before initialization。

// lib.mjs export function hello() { return 'hi'; } export const value = 42; // main.mjs import { hello, value } from './lib.mjs'; console.log(hello()); // 正常 console.log(value); // 正常,因为 lib 已经求值完毕

但在循环依赖的场景下:

// a.mjs import { bValue } from './b.mjs'; export const aValue = 'a'; console.log(bValue); // 若 b 先求值,这里可能报 TDZ 错误 // b.mjs import { aValue } from './a.mjs'; export const bValue = 'b'; console.log(aValue); // 这里是安全的,因为 a 已经部分求值

按 ESM 规范,循环依赖中如果某个模块在依赖未完成求值时就去访问对方的const绑定,就会触发 TDZ 错误。这和 CJS 的"返回空对象然后静默继续"完全不同——ESM 选择报错,我认为这是更负责任的设计,虽然报错信息对新手不太友好。

再补一个容易忽略的点:ESM 模块顶层this是undefined,不是module.exports,也不是全局对象。有些从 CJS 迁移过来的代码里写了this.foo = 1,迁过去直接崩。还有__dirname和__filename在 ESM 里不存在,需要自己从import.meta.url推导:

import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);

4. 两套规范的核心差异,逐条对照着看

4.1 加载时机与解析规则

对比维度CommonJSES Module
加载时机运行时,执行到才加载编译期确定依赖图
依赖声明位置任意位置均可必须顶层
路径后缀可省略,自动补全必须写全后缀
相对路径写法require('./x')必须./或../开头
目录导入支持(找 index)不支持(除非 exports 映射)
变量名提升var、函数声明提升导入绑定提升,值受 TDZ 约束
顶层 this指向 module.exportsundefined
循环依赖表现返回未完成的 exports 对象活绑定,访问未初始化值报错
动态加载require()同步调用import()返回 Promise
Tree-shaking基本无法做静态分析可做

路径后缀这条规则坑过太多人。.mjs文件里写import utils from './utils',Node 会直接抛ERR_MODULE_NOT_FOUND。这不是配置问题,是 ESM 规范要求必须写明确的文件标识。好处是解析结果确定、不依赖文件系统探测顺序;代价就是从 CJS 迁移时几乎要把所有require路径重写一遍。我在做迁移时用的办法是先批量跑一个脚本把相对路径补全后缀,然后手动过一遍node_modules里的包名——包名本来就不需要后缀,受影响的主要是相对路径。

4.2 缓存策略与状态共享的差异

CJS 的缓存是require.cache,一个普通对象,你可以直接delete require.cache[path]来强制重新加载。这个方法在不重启进程的情况下修改配置、热更新插件时特别有用。副作用是:被删缓存的模块如果被别人引用着,那些引用还是旧对象,容易出现状态不一致。用完记得把依赖链一起清。

ESM 的缓存更严格,模块只求值一次,靠的是引擎内部的模块映射表。没有公开的清理接口,也没有delete这种操作。这意味着 ESM 模块的状态天然全局唯一,想重置只能重新设计(比如把状态收进一个工厂函数)。做测试时这一点差异很明显:CJS 里可以靠清缓存隔离用例,ESM 里就得靠依赖注入或者mock机制。

两者在"单例"这个语义上其实殊途同归——同一个模块路径在同一个进程里只会有一份实例。区别在于 CJS 留了个后门给你手动操作,ESM 把这个后门焊死了。我个人的判断是,绝大多数业务代码不需要那个后门,焊死反而更安全。

4.3 各自适合什么场景

CJS 的优势在于同步加载足够简单,对服务端这种"文件在本地磁盘、读一下很快"的场景非常合适。它的动态性也允许做一些 ESM 做不到的事,比如根据运行时变量拼路径再require、条件加载可选依赖、运行时注册插件。这类模式在一些老牌框架里大量存在。

ESM 的优势在于静态可分析,前端需要打包、需要摇树、需要做代码分割的场景几乎必然选它。另外在浏览器原生支持的语境下,ESM 是唯一能直接跑的模块形式。

一个常被忽略的事实:现代 Node 项目完全可以只写 ESM。新项目如果没有历史包袱,我一般建议直接从 ESM 起步,用"type": "module"声明,省掉以后迁移的成本。至于生态里还有大量只提供 CJS 的包,这个问题在较新的 Node 版本里已经有了缓解方案,下面会展开。

5. 实操:让两套规范在同一个项目里共存

5.1package.json里的字段,到底谁说了算

这是配置类问题里最容易绕晕的部分。package.json里跟模块相关的字段有好几个,作用各不相同:

字段作用谁在用
type声明.js文件按 ESM 还是 CJS 解析Node 运行时
main传统入口,CJS 时代的标准Node(兜底)、老打包器
module指向 ESM 入口部分打包器约定,不是标准
exports现代条件导出映射,优先级最高Node 12.7+、打包器
browser浏览器环境替代入口部分打包器

关键规则:只要存在exports字段,main和module基本就不参与解析了(除了个别打包器的兜底逻辑)。exports支持条件匹配,按照声明顺序第一个命中的条件生效,所以顺序很重要。

{ "name": "demo-pkg", "type": "module", "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.mjs", "require": "./dist/index.cjs", "default": "./dist/index.mjs" }, "./utils": { "import": "./dist/utils.mjs", "require": "./dist/utils.cjs" }, "./package.json": "./package.json" } }

几个实操要点。types条件必须放第一位,否则 TypeScript 可能匹配不到;default永远放最后,它是兜底。./package.json这条映射常被忘记,但很多工具会读它,缺了会报错。另外exports是封闭的——一旦声明,包内其他路径就不能再从外部导入,这既是安全特性也是迁移时的坑:老代码里那些import 'pkg/lib/xxx'的深度导入会全部失效。

type字段的默认值是commonjs,也就是说.js文件默认按 CJS 解析,除非你显式声明"type": "module"。而.mjs永远按 ESM、.cjs永远按 CJS,不受type影响。这三者组合起来就是完整的解析规则,没有例外。

5.2 互操作:两种方向,规则完全不同

方向一:CJS 里用require加载 ESM。

传统上这会被拒绝,报ERR_REQUIRE_ESM。较新的 Node 版本已经支持require()加载 ESM 模块,但有个限制:被加载的 ESM 不能包含顶层await,否则仍然报错(因为require是同步的,没法等 Promise)。返回值形态上,如果目标模块有默认导出,取的是默认导出;否则返回类似命名空间的对象。

兼容性考虑,跨版本运行的项目不要依赖这个能力,老老实实用动态import():

// 在 CJS 文件里加载 ESM async function loadEsm() { const mod = await import('./esm-module.mjs'); return mod.default ?? mod; }

方向二:ESM 里用import加载 CJS。

这是最常用也最容易踩坑的方向。Node 会把 CJS 的module.exports整体当作默认导出,同时用静态分析尝试提取命名导出(靠的是一个叫cjs-module-lexer的机制)。

import pkg from 'some-cjs-pkg'; // 默认导出:整个 module.exports import { named } from 'some-cjs-pkg'; // 命名导出:靠静态分析猜测

坑就在这里:命名导出不是百分之百可靠。如果 CJS 模块导出方式比较动态,比如module.exports = Object.assign({}, a, b)或者循环挂属性,静态分析就识别不出来,import { named }会拿到undefined而不报错——这种静默失败最难查。判断办法是先import pkg from 'x'打印一下,看看结构,再决定用哪种写法。保守策略是永远用默认导出 + 解构:

import pkgNs from 'some-cjs-pkg'; const { named } = pkgNs;

在 ESM 里如果确实需要require,可以用createRequire造一个出来:

import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const legacy = require('./legacy-module.cjs');

5.3 双包问题:一份代码打两遍的隐患

当一个包同时提供import和require两个入口时,如果两个入口各自打包了一份完整实现,就会出现同一个包在同一个进程里存在两份实例的情况。表现是:你在 ESM 侧设置的单例状态,在 CJS 侧读不到;instanceof判断莫名失败;上下文对象不匹配导致"明明传了却拿不到"。

这个问题的根源在于模块缓存是以"解析后的文件路径"为键的,index.mjs和index.cjs是两个不同文件,自然是两份缓存、两份状态。

症状可能原因处理方式
instanceof判断失败双包导致两个类统一入口,或改用鸭子类型判断
单例状态不同步双包导致两份缓存把状态抽到独立包,两个入口共同依赖
上下文丢失双包导致两个上下文实例检查依赖树里是否有重复包
类型定义报错types条件顺序不对调整exports中types位置

常见做法是:把核心状态和逻辑抽到一个不依赖模块形式的纯实现文件里,两个入口都只做薄薄一层转发,这样不管从哪边进来,底层的状态都是同一份。或者干脆只提供一个入口,另一个方向通过互操作兜底——牺牲一点纯净度换取一致性,我觉得划算。

5.4 TypeScript 编译侧的三个关键开关

用 TypeScript 的项目还要多一层配置。tsconfig.json里module决定输出什么模块格式,moduleResolution决定怎么解析路径,这两个必须和目标运行环境匹配。

配置项可选值说明
modulecommonjs/esnext/nodenext决定产物格式
moduleResolutionnode/bundler/nodenext决定路径解析策略
esModuleInteroptrue/false允许import x from加载 CJS 默认导出
allowSyntheticDefaultImportstrue/false类型层允许合成默认导入

esModuleInterop打开后会生成一层包装,让import x from 'cjs-pkg'能正确拿到module.exports。不开的话,某些包的默认导入会是undefined。moduleResolution: bundler是给打包器用的,允许省略后缀;设成nodenext则强制要求后缀,和 Node 的原生 ESM 规则一致。我见过最典型的报错是类型能过、编译能过,运行时ERR_MODULE_NOT_FOUND,基本都是因为 TS 用的是bundler解析、但产物直接丢给 Node 跑。

6. 常见问题排查实录

6.1 高频报错速查

报错信息根因解决方向
Cannot use import statement outside a module文件被当作 CJS 解析但有 import改type或改后缀为.mjs
require is not defined在 ESM 或浏览器环境用了 require改用 import,或用createRequire
ERR_REQUIRE_ESMCJS 里 require 了 ESM改动态import(),或升级运行环境
ERR_MODULE_NOT_FOUNDESM 路径缺后缀或包名解析失败补全后缀,检查exports映射
Cannot access before initialization循环依赖触发 TDZ调整依赖顺序,抽公共模块
default is not a functionCJS 与 ESM 默认导出互操作理解偏差打印模块结构确认形态
__dirname is not definedESM 里没有该变量从import.meta.url推导

6.2 三个我实际踩过的坑

坑一:路径大小写。在大小写不敏感的文件系统上开发,import './Utils.js'和实际文件名utils.js能跑通。部署到区分大小写的环境上直接 404。这类问题本地永远复现不了,只能靠 CI 在 Linux 环境跑一遍,或者加个 lint 规则强制检查导入路径与文件名一致。我现在所有项目都会开这个检查,成本很低。

坑二:exports字段封闭性导致的深度导入失效。某个历史项目里大量出现import 'some-pkg/dist/helpers'这种写法,把依赖升级到声明了exports的版本后,全部报ERR_PACKAGE_PATH_NOT_EXPORTED。修复办法要么是降级,要么是让包方在exports里补上对应路径。教训是:依赖的包一旦引入exports,就相当于收紧了公共 API,升级前必须过一遍深度导入清单。

坑三:测试框架里的模块缓存。CJS 时代用delete require.cache做用例隔离的习惯,迁到 ESM 后完全失效,因为 ESM 没有公开的缓存清理接口。同样一份 mock 配置在多个用例间共享,用例之间互相污染。最后的解法是把需要隔离的状态改成工厂函数按需创建,或者用测试框架提供的模块 mock 能力。这件事让我意识到,ESM 的"模块只求值一次"不是限制,而是逼你把状态管理做干净。

6.3 一个通用的排查套路

遇到模块相关报错,我一般按这个顺序走:

先确认当前文件被当成什么格式解析。看后缀是.mjs/.cjs还是.js,如果是.js就看最近一层package.json的type字段。这一步能解决大概一半的问题——很多人只是不知道自己写的是哪个格式。

再确认依赖解析到了哪个文件。在 CJS 里可以用require.resolve('xxx')打印绝对路径;在 ESM 里则看报错堆栈里给出的file://地址。如果发现解析到的路径和你预期的不一样,那基本就是exports映射或者node_modules层级的问题。

最后确认模块之间的求值顺序。在入口和可疑模块的顶部各打一行日志,看输出顺序。循环依赖几乎都能靠这一步暴露出来。如果发现某个模块在被依赖时还是空的,那就是求值顺序问题,要么调整引用关系,要么把引用改成延迟执行。

顺带说一个容易被忽略的细节:exports字段里的条件匹配是按声明顺序来的,不是按"最具体优先"。所以default一定要放最后,types一定要放最前。写反了不会报错,只会静默匹配到错误的分支,这类问题排查起来最费时间。

7. 迁移这件事,我的实际做法

真要动手把老项目从 CJS 迁到 ESM,我不建议一次性全改。我试过,一个两千多文件的项目,改完第一天基本处于"能跑但不知道为什么能跑"的状态,后面一个月都在还债。

更靠谱的路子是自下而上。先找出那些依赖极少、只导出纯函数或常量的叶子模块,改成 ESM,观察一段时间。这类模块没有状态、没有副作用,改坏了影响面也小。然后再往上推一层,处理有状态的服务模块、配置加载、中间件注册这些。最后才动入口文件。

迁移过程中有几个必须提前处理的东西:__dirname、__filename、require.resolve、动态拼接路径的require、以及依赖 CJS 缓存机制做热更新的逻辑。前三个有标准替代方案,动态拼接路径的那个要改成import()加变量,热更新那部分基本得重写。还有 JSON 导入,CJS 里require('./config.json')直接拿到对象,ESM 里要么用导入断言(不同版本语法还在演进),要么用fs.readFileSync读一遍再 parse。我一般选后者,写个小工具函数包一下,省得跟语法版本纠缠。

有一个判断标准我觉得挺实用:如果一个项目的产物最终要经过打包器再进浏览器,那模块格式的选择其实没那么严格,打包器都会处理;如果产物是直接被 Node 执行的,那就要老老实实跟着运行时的规则走。搞清楚产物去哪,比研究哪个规范"更先进"有用得多。

反过来,如果你维护的是一个要被别人依赖的库,那就得考虑得更多。我的建议是同时提供两个入口,通过exports条件映射区分,但底层实现共用同一份代码,只做薄转发。这样既照顾了旧生态,又避免了双包带来的状态分裂。至于要不要维护两套构建产物,取决于用户群体里还有多少老环境,这个得看数据说话,不能拍脑袋。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询