⭐ 推荐:社区规则条款 V1.0

模块化:从CommonJS到ESM,模块依赖循环引用的坑你踩过吗?

最长的电影
最长的电影 正式会员正式会员
发布于 2026-08-17 13:11 ·94 浏览 ·0 回复
内容摘要

从 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,先别急着改代码,画一画依赖图,坑可能就在那个环上。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-233.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~