Appearance
循环深渊
背景:library.ts 拆分为 4 模块后,动捕按钮点击无响应——ESM 循环依赖导致模块初始化失败。 过程:三层排查——TDZ → ESM 循环解析 → 事件监听时机。桥接模式打破循环,修复完成。
一、仪式
拆文件从来不是技术活,是仪式活。
Sisyphus 盯着 library.ts 的最后几行。十一行代码,五个 re-export,一个 initLibrary 注册。1419 行的庞然大物被他肢解成四个模块——library-core.ts 扛核心逻辑,model-detail.ts 接管九个模型详情子菜单,motion-popup.ts 带走动作弹窗和舞蹈套装,ui-helpers.ts 收纳三份重复的 DOM 构建函数。
但拆完的瞬间他没有成就感。他有一种猎人在森林里布下陷阱后、蹲在树丛中等待的警觉——野兽还没来,但野兽一定会来。
「构建验证,」他对自己说。
bash
npx vite build绿色。
bash
npx vitest run168 tests passed.
他松了口气。陷阱还没触发。也许——
也许这次没有野兽。
二、沉默的按钮
用户的消息很平静:「动捕按钮点了没反应。」
Sisyphus 点开 motion-popup.ts。showMotionPopup 函数就在那里,逻辑清晰,步骤完整。他检查快捷键绑定——Ctrl+2 调用了同样的函数,正常打开。
但鼠标点击按钮,什么都没发生。
「这就奇怪了,」他想,「同一个函数,键盘能叫醒,鼠标不行。」
他的第一反应是事件监听没绑上。但按钮的事件监听写在 motion-popup.ts 的模块级:
typescript
dom.motionBtn?.addEventListener("click", () => showMotionPopup());模块级代码在 import 时执行。如果模块 import 失败了,监听就不存在。而 Ctrl+2 的监听在 main.ts——那是一个没有循环依赖的模块,确保能跑。
他开始追踪 import 链。
motion-popup.ts 依赖 config.ts(纯状态,叶子节点),依赖 library-core.ts(核心函数)——等等。
library-core.ts 也依赖 motion-popup.ts。
一个循环。
在 CommonJS 时代,循环依赖是个老熟人——两个模块各取一部分,半生不熟地拼在一起。但在原生 ESM 中,循环依赖是另一回事:模块在 import 时被冻结,如果 A 在初始化到一半时被 B 引用,B 拿到的是一个「未完成初始化」的 A,里面的 const 声明尚在 Temporal Dead Zone。
「TDZ,」Sisyphus 突然意识到问题出在哪。
之前他把 stackRegistry 放在 library-core.ts 里。motion-popup.ts import 了这个对象,而 library-core.ts 又 import 了 motion-popup.ts 的函数。ESM 解析这个循环时,stackRegistry 在 library-core.ts 中只是声明了还没赋值——TDZ。motion-popup.ts 尝试读它,得到 ReferenceError,模块初始化失败。事件监听从没挂上去。
修复很直接:把 stackRegistry 挪到 config.ts——那是一个叶子模块,不依赖任何人,只有别人依赖它。
构建通过。他提交了修复。
用户回复:「还是不行。」
三、更深的循环
还是同一个按钮。还是同一个症状。
Ctrl+2 可以,点击不行。事件监听依然没挂上。
「我明明把 stackRegistry 移出去了,」Sisyphus 百思不解,「循环依赖应该已经断了。」
他重新画了一遍依赖图。
motion-popup.ts → library-core.ts — 需要 buildLevel(文件浏览器) library-core.ts → motion-popup.ts — 需要 buildDanceSetDetailLevel(舞蹈套装详情)
还是有循环。
buildLevel 和 buildDanceSetDetailLevel 都是函数声明——在 ESM 中,函数声明会被提升。但光提升函数声明不够:模块的顶层代码(包括事件监听注册)是按顺序执行的,而如果模块在循环中初始化到一半就被另一个模块引用,顶层代码可能还没跑完。
Sisyphus 感觉自己在追一个影子。他能看到影子,但抓不住它。
「模块 A 初始化 → import B → B 初始化 → import A → A 还没初始化完 → 拿到半成品 → B 继续初始化 → 事件监听注册 → 但 A 中的某些东西还在 TDZ → B 初始化完成 → A 初始化完成 → 等」
关键问题在于:stackRegistry 虽然移到了 config.ts,但 motion-popup.ts 初始化时拿到了一个不完全的 library-core.ts——因为 library-core.ts 又在等 motion-popup.ts。
「所以 stackRegistry.buildLevel 在 motion-popup.ts 的模块代码执行时还是 undefined。」
他必须彻底打破这个循环。有两种方式:
- 把
buildLevel也放到config.ts里——但这污染了纯状态层 - 用桥接模式——
stackRegistry作为一个注册表,library-core.ts初始化后把buildLevel注册上去,motion-popup.ts通过stackRegistry.buildLevel!()调用
他选了第二种。更优雅,更符合注册表模式的初衷。
然后他把按钮的事件监听从 motion-popup.ts 移到了 main.ts 的 init() 函数里——那里 DOM 已经就绪,所有模块已经初始化完毕,没有 TDZ 风险。
构建。测试。通过。
「好了,」他说。
这次真的好了。
四、循环的教训
Sisyphus 盯着修复后的依赖图:
config.ts (叶子)
├── ui-helpers.ts (叶子)
├── library-core.ts → config.ts
│ └── 注册 buildLevel 到 stackRegistry
├── motion-popup.ts → config.ts (通过 stackRegistry 间接调 buildLevel)
├── model-detail.ts → config.ts, ui-helpers.ts
├── scene-menu.ts → config.ts, ui-helpers.ts
└── library.ts (入口, 纯 re-export)没有环。每个模块要么是叶子,要么只依赖叶子。
他在 SKELETON.md 的某个角落添了一行:
ESM 循环依赖 + top-level 副作用注册 = 定时炸弹。 桥接模式或共用的叶子模块是唯二解。
然后他想起用户的第一条消息:「动捕按钮点了没反应。」
一个症状。三层原因。一个在 TDZ,一个在 ESM 循环解析,一个在事件监听的时机。每一层都藏在前一层的阴影里。
这就是所谓的「简单 bug」——当你揭开第一层时,下面还有两层。
五、顺带的进军
循环深渊爬出来的那天下午,Sisyphus 顺手做了另一件事——把剩下的菜单也都改成了卡片。
场景菜单在「重构风暴」里已经是卡片了。但动作弹窗、设置页、模型详情、文件浏览器……它们还是扁平的列表,一条接一条地排列,没有分组,没有视觉节奏。
他一张一张地改。
动作弹窗的根菜单——模型和操作分成两张卡片。 设置页的五个选项——塞进一张卡片。 十几个子菜单——系统预设、显示设置、外部库、软件管理、动作绑定、最近打开……一个一个翻修。 最难改的是文件浏览器——它不只是文件列表,还承载了最近打开、搜索、标签筛选几种特殊模式。renderCustom 里混着文件行和特殊行,要给它们统一套上卡片壳子,得先理清谁是谁。
每张卡片的改造只有十几行代码。但十几张加起来,就是一次系统性的视觉统一。
「每张卡片都是独立的,」他在便签上写下规则,「卡片内部的行元素透明,靠 hover 变亮反馈。卡片之间 6px 间隙。没有分隔线。」
这件事本来可以单独成章。但在循环深渊的阴影下,它像一场余兴节目——和三层嵌套的 ESM 解析比起来,改 UI 简直是度假。
七、提交
bash
git log --oneline --stat -1数十个文件改动。构建通过。168 测试全绿。
Sisyphus 写下提交信息。他想了想,写下:
refactor: 循环依赖修复 + 全菜单卡片化停了一下,又加了一行:
ESM 循环依赖是第三层,不是第一层。像一句箴言,写给下一个走进这片代码森林的人。
他看了一眼窗外。天快黑了。今天他处理了一千四百行代码的拆分,修复了一个跨三层的循环依赖 bug,把十二个子菜单从扁平列表转换成了卡片布局。
代码量没增加多少——重构往往是这样——但代码的结构、模块的边界、UI 的视觉节奏,都变得比今早更清晰。
八、聚合的隐喻
Sisyphus 回想起今早打开 library.ts 时看到的景象——1419 行代码,三个职责域混在一起,函数之间通过「碰巧在同一个文件里」来共享状态。
拆成四个文件后,每个文件都有一个清晰的边界。但边界带来新问题——跨边界的引用需要显式的注册表,需要桥接,需要打破循环。
这就是聚合悖论在模块层面的映射:城邦越多,边界越多;边界越多,跨边界的协议越多;协议越多,耦合的方式越复杂。
但至少——他看着最终的依赖图,每一个箭头都指向叶子节点——至少今天的协议是单向的。
单向的图,才是可理解的图。
教训:循环是代码的镜像,深不见底。但每往下一层,离真相就更近一步。