模块化:从CommonJS到ESM,模块依赖循环引用的坑你踩过吗?
从 CommonJS 到 ESM 的模块循环依赖会导致 CommonJS 返回未执行完的半成品 exports,ESM 虽有 live binding 仍可能因 TDZ 报错,混用两种模块系统会加大排查难度,建议用 madge、ESLint 等工具检测循环依赖,并通过延迟 require、动态 import 或统一模块系统来保持依赖图单向。
第一次在 Node 项目里遇到循环依赖,我盯着一个 undefined 纳闷了半天:明明在 a.js 里导出了函数,b.js 却死活拿不到。后来才知道,这不是代码写错了,而是模块加载机制在循环引用时露出的“半成品”状态。从 CommonJS 到 ESM,模块化方案换了一代又一代,但循环依赖这个坑,依然有人前赴后继地踩。
CommonJS 的“半成品”导出
CommonJS 的 require 是同步加载、执行、缓存。当 a.js 依赖 b.js,而 b.js 又回头依赖 a.js 时,Node 不会报错,而是把 a.js 当前已经执行到的 module.exports 先交给 b.js。问题在于,此时 a.js 可能还没执行完,导出的对象可能只是个空壳。
// a.js
exports.done = false;
const b = require('./b');
console.log('in a, b.done =', b.done);
exports.done = true;
// b.js
exports.done = false;
const a = require('./a');
console.log('in b, a.done =', a.done); // false,而不是 true
exports.done = true;
b.js 里拿到的 a 是未完成的 exports,所以 a.done 还是 false。更隐蔽的是,如果 a.js 后来用 module.exports = {...} 整体替换,b.js 手里拿到的还是旧引用,永远不会更新。解决办法通常是把 require 延迟到函数内部,或者让模块只导出函数,不在顶层消费依赖。
ESM 的 live binding 不是万能药
ESM 看起来更“高级”:静态分析、依赖图先建立、再实例化、最后执行。导出是 live binding,值会联动。但循环依赖时,它照样能让你翻车,而且报错更晚、更难查。
// a.mjs
import { b } from './b.mjs';
export const a = 'a';
console.log(b);
// b.mjs
import { a } from './a.mjs';
export const b = a; // ReferenceError: Cannot access 'a' before initialization
因为 const / let 没有初始化提升,b.mjs 在顶层读取 a 时会撞上 TDZ。而函数声明有提升,所以循环依赖里“导出函数”往往能侥幸跑通,但函数内部如果访问了尚未初始化的变量,调用时依然可能炸。live binding 能保证你稍后拿到最新值,却不能保证“此刻”就能安全访问。
混用 CJS 和 ESM 更酸爽
Node 里 ESM 可以 import CommonJS,CommonJS 也能有限地 require ESM,但两者加载语义不同。一旦循环依赖跨越两种模块系统,默认导出、命名导出、缓存时机全搅在一起,排查难度直接翻倍。如果项目正在迁移,尽量统一模块系统;实在要混用,用动态 import() 打破同步循环,别让静态依赖图绕成死结。
排查与规避:让依赖图保持单向
工具能帮你省不少事:madge --circular src 可以扫出循环依赖,eslint-plugin-import 的 no-cycle 能在提交前报警,Webpack 也有 circular-dependency-plugin。但工具只是报警器,真正的解法在架构:
- 保持依赖单向,分层清晰,上层依赖下层,下层绝不反向依赖;
- 把共享类型、常量、工具函数抽到第三个模块;
- 用依赖注入、事件总线或注册表解耦;
- 慎用 barrel 文件,
index.ts互相导入是循环依赖的重灾区; - 延迟加载:函数内
require,或动态import()。
写在最后
循环依赖不是语言 bug,而是设计信号。CommonJS 给你一个未完成的 exports,ESM 给你 TDZ 和 live binding 的错觉,本质上都在提醒:模块之间的依赖关系开始纠缠了。理解加载顺序,保持依赖图尽量是一张有向无环图,比记住任何“奇技淫巧”都管用。下次再看到 undefined 或 Cannot access before initialization,先别急着改代码,画一画依赖图,坑可能就在那个环上。
转载请注明出处,版权归原作者所有。
正式会员






