同一个导出,为什么 import 拿到活值,require 解构后只剩快照

CommonJS 用 require 在运行时同步加载并求值,exports 只是一个普通对象;ESM 则在执行前先解析、链接整张依赖图,import 拿到的是指向导出模块内部变量的只读活绑定。这个差别决定了循环依赖、双包发布、按需加载与 tree shaking 的表现,也解释了为什么把 ESM 用 Babel 转回 CJS 后,某些行为会悄悄变样。本文从加载时机、绑定语义与 Node 的互操作规则讲起,拆开 require 与 import 最容易答错的几个点,并给出从 CJS 迁移到 ESM 时可执行的步骤。理解这套差异,才能判断什么时候该保留 CJS 包装、什么时候必须整体切到 ESM。

是什么

模块化要解决三件事:把变量关进自己的作用域、把依赖关系写清楚、让同一份代码能被别处复用。CommonJS 是 2009 年随 Node 一起流行起来的方案,用 require 同步取模块、用 module.exports 交出接口;ESM 是 ES2015 写进语言标准的模块系统,用 import / export 声明依赖,由宿主在运行前统一装配。

一句话直觉:CJS 像小馆子点菜,客人喊一声 require,后厨才现做,拿到的是当时那盘菜;ESM 像开席前先排好整桌座位与上菜顺序,import 只是座位牌,菜端上来后牌子上指向的那盘会一直更新。

典型场景包括:维护一个仍有大量 CJS 依赖的 Node 服务、给 npm 包同时提供 import 与 require 两个入口、把前端代码交给打包器做静态分析,以及把老项目一步步迁到 "type": "module"。这些场景里真正麻烦的往往不是改写法,而是两套模块系统对“值”和“时机”的定义不同。

核心原理

先看 CJS。Node 在执行一个 .cjs 文件前,会把它包进一个函数,参数是 exports、require、module、__filename、__dirname。所以 require 就是一个普通函数调用:可以写在 if 里,可以拼路径,也可以在函数体内延迟执行。第一次加载时 Node 执行整个文件,把结果放进 module.exports,再按解析后的绝对路径缓存;之后再 require 同一个文件,直接返回缓存里的那个对象,不会重复执行。

这套机制带来两个后果。第一,导出的是对象属性,属性里存的是值或引用;如果你把 count 解构出来,拿到的是当时那个数字的副本,后续模块内部再怎么加,副本不会变。第二,循环依赖时,先被加载的一方只能拿到对方“此刻已经挂上去”的半成品 module.exports,所以经常出现某个属性是 undefined 的时序问题。

ESM 走的是另一条路。规范把模块加载拆成解析/构建、链接、求值三个阶段:先静态解析出整张依赖图,再为每个模块建立作用域并把导入和导出连起来,最后按后序深度优先执行。import 和 export 只能出现在顶层,路径必须是静态字符串,所以打包器不用执行代码就能知道谁依赖谁。导入的不是值的拷贝,而是指向导出模块内部绑定的只读视图,导出方改变量,导入方能看见,这就是“活绑定”;反过来给导入的标识符赋值,在编译期就会报错。模块默认是严格模式,顶层 this 是 undefined,也没有 __dirname 和 require。

// counter.mjs
export let count = 0;
export function inc() { count += 1; }

// main.mjs
import { count, inc } from './counter.mjs';
inc();
console.log(count); // 1:活绑定,能看到导出方的修改

// counter.cjs
let count = 0;
module.exports = { count, inc() { count += 1; } };

// main.cjs
const { count, inc } = require('./counter.cjs');
inc();
console.log(count); // 0:解构时复制了当时的值

还需要注意求值顺序:ESM 会先执行依赖,再执行依赖者;遇到顶层 await,这个模块以及依赖它的模块都会变成异步求值,导入方会等待。CJS 则是“执行到 require 那行才进去”,顺序由代码位置决定。两者都没有“谁更先进”的问题,差别是静态可分析性与时序可控性。

CJS 的设计适合本地磁盘:同步读取足够快,API 也简单。但把同一套代码搬到浏览器就行不通——网络请求不能同步阻塞主线程;更关键的是 require 是普通函数,打包器无法在不执行代码的情况下确定依赖,只能把可能用到的文件都塞进产物。ESM 把依赖声明变成语法的一部分,宿主可以先拿到完整依赖图再决定加载什么,这也正是 tree shaking、代码分割和浏览器原生模块能够成立的前提。

互操作是迁移期的主战场。ESM 导入 CJS 时,default 一般就是对方的 module.exports;具名导入靠 cjs-module-lexer 做静态猜测,动态拼出来的属性名常常识别不到,所以拿 default 再解构更稳。反方向,从 Node 20.19 / 22.12 起,CJS 也可以用 require() 加载 ESM,返回模块命名空间对象,前提是那个 ESM 没有顶层 await,否则会抛 ERR_REQUIRE_ASYNC_MODULE。这解决了很多“依赖是 ESM-only”的燃眉之急,但不改变两套语义的差别。

常见考点与误区

  • “ESM 只是 CJS 的语法糖,Babel 一转就一样”:错。转译能模拟活绑定(通常用 getter),但 import 提升、TDZ、顶层 await、异步求值顺序这些语义很难完整保留;转译产物跑的是 CJS 的时序,调试时会出现源码与运行时对不上的情况。
  • “可以直接给 exports 赋值来替换整个导出”:错。函数参数 exports 一开始只是 module.exports 的别名,给 exports 重新赋值只会切断这个局部别名,缓存里交出去的仍是旧的 module.exports。要整体替换接口,必须写 module.exports = ...。
  • “循环依赖在 ESM 里也会拿到 undefined”:不一定。ESM 在求值前就建好了绑定,函数声明在提升后可用;但 let / const 在初始化前处于暂时性死区,访问会抛 ReferenceError。CJS 则是拿到当时已经赋值的属性,可能是半成品,两者错法不同。
  • “export default foo 和 export { foo as default } 等价”:不等价。前者把 foo 当时的值初始化给 default 绑定,之后 foo 再变,默认导入不会跟着变;后者才是指向 foo 的活绑定。这是默认导出最常见的坑。
  • “一个包同时提供 ESM 和 CJS 入口最稳”:可能踩双包危害。应用 import 到 ESM 版本、某个依赖却 require 到 CJS 版本时,内存里会有两份模块、两份状态,单例配置、instanceof、计数器都会分裂。要么把状态收敛到一份共享的 CJS 文件,要么用 module-sync 条件让 require 也拿到同一份 ESM 实例。
  • “ESM 里没有 __dirname 是 Node 的 bug”:这是规范差异。可以用 import.meta.dirname(Node 20.11 / 21.2 起),或用 fileURLToPath(new URL('.', import.meta.url));需要 require 时用 createRequire(import.meta.url) 造一个。

实践建议

  • 新项目优先 ESM:在 package.json 里写 "type": "module",相对导入补全扩展名,用 import.meta.url 处理路径;需要加载 CJS 依赖时,优先确认它是否提供 ESM 入口,而不是一上来就全量转译。
  • 迁移从叶子包开始:先改没有内部依赖的工具模块,再改上层;每改一层就跑一遍测试,重点覆盖循环依赖、单例初始化和动态 require。不要一次性把整个仓库改成 ESM 再集中排错。
  • 发布 npm 包时把 exports 写清楚,types 条件放最前;如果同时提供两个入口,务必对 import 和 require 两条路径分别做集成测试,确认状态没有分裂。能只发一份 ESM 时,优先用 module-sync 兼容老消费者(前提是模块不含顶层 await)。
  • 不要依赖具名导入去读 CJS 的动态导出;默认导入再解构更安全。反过来,CJS 里 require ESM 时,先把带顶层 await 的模块排除掉,或改成异步 import()。
  • 循环依赖不要靠“加载顺序刚好对”来维持。把共享状态抽到第三个模块,或用依赖注入打破环,比记住谁先执行可靠得多。

一句话总结:CJS 在运行时交出对象、解构即快照,ESM 在运行前连好只读活绑定,迁移要先迁时序与边界,而不只是改写法。

相关阅读

← 返回文章志