Files
TrueGrowth/docs/CYCLE_DEPENDENCY_LESSONS.md

7.0 KiB
Raw Blame History

循环依赖治理经验

更新日期2026-04-27

背景

本轮目标是继续收敛项目里的循环依赖:先把运行时静态 import、type-only import 清到 0再单独收敛 dynamic import 形成的懒加载 SCC。最终 runtime-staticstatic-alldynamic-inclusive 三层扫描均为 0。

本轮结论

1. 循环依赖要分层看

推荐至少区分三层:

  • runtime-static:运行时静态 import/export必须作为失败项。
  • static-all:运行时静态 + type-only import/export适合作为架构洁净度检查。
  • dynamic:动态 import 造成的懒加载图回边,适合单独专题治理。

经验不要只看“有没有环”要先判断它会不会影响初始化顺序、chunk 分包、首屏边界或类型层耦合。

2. type-only 环也值得收敛

TypeScript 的 type-only import 不会直接进入运行时代码,但它会暴露模块职责不清的问题。本轮剩余问题主要来自:

  • workflow converter 同时承载类型和转换逻辑。
  • settings manager 同时承载配置类型、常量和运行时管理器。
  • model adapter barrel 带有默认注册副作用,被类型引用误触。

经验:类型环通常不需要大改逻辑,优先抽出中立纯类型模块,让业务模块都向下依赖。

3. 桶文件不是类型边界

index.ts barrel 很方便,但如果 barrel 同时 re-export 有副作用的运行时模块,就不适合作为纯类型 import 来源。

推荐做法:

  • 类型引用直连 types 或低层纯类型模块。
  • 有副作用的 barrel 只给运行时功能入口用。
  • 公共 API 需要兼容时,在旧入口 re-export 类型,但内部新代码不要再从旧入口回引。

本轮例子:

  • audio-api-service 的结果类型改为直连 model-adapters/types
  • gemini-api/types 改为直连 provider-routing/types
  • settings-manager 继续 re-export settings-types,但 provider-routing 内部不再回引它。

4. 中立类型模块要保持“轻”

抽出的类型模块只应依赖更底层的纯类型或常量类型,不应引入服务、单例、注册表、存储读写或 UI 组件。

本轮新增的边界:

  • workflow-types:只承载 workflow 数据形状。
  • settings-types:只承载 settings 数据形状和纯常量。

经验:中立模块一旦混入运行时逻辑,很快会变成新的公共泥球。

5. 检查脚本默认要保守

pnpm check:cycles 继续只检查运行时静态环,避免突然扩大 CI 失败面。新增 pnpm check:cycles:types 专门检查 type-only 环,适合在架构收敛或重构后复跑。

经验:检查项升级要分阶段,让默认门禁稳定,再逐步把更严格模式纳入常规流程。

6. dynamic import 环要先找“元数据拉注册表”

本轮 dynamic-inclusive 大 SCC 最初有 97 个文件,第一刀不是改组件,而是切断内置工具元数据对工具注册表的依赖:

  • built-in-tools 只读取 manifests不读取 toolRegistry
  • ToolPluginModule 移到纯类型文件,工具插件不再 type-import registry。
  • 模型测试启动器只读内置 manifests不为查一个 tool manifest 拉入组件注册表。

结果97 文件大 SCC 立即拆成 22 + 5 + 5。经验是:如果“常量/元数据”反向 import 了“注册表/组件加载器”,懒加载图很容易被拖成大团。

7. 通用 UI 组件不要默认回拉业务能力

剩余 22 文件 SCC 的关键边是 MediaViewport -> canvas-operations -> card -> MarkdownEditor -> media-library -> shared。处理方式:

  • 媒体预览组件只依赖轻量 image/video 插入 helper不直接 import 全量 canvas-operations
  • canvas-board-ref 独立承载 board 引用,避免轻量插入能力为了拿 board 反向拉入文本/Card 插入链。
  • media-library 内部使用 leaf import不从 shared/index 桶文件拿 HoverTipContextMenu

经验:通用展示组件可以提供兜底能力,但兜底能力必须是窄入口;否则一个“插入到画布”按钮就会把 Card、Markdown、素材库一起拉回预览组件。

8. 运行时单例互访用 bridgeTransform 和组件分离

两个 5 文件小 SCC 分别来自 service 互访和插件组件互访:

  • GitHub sync 需要刷新 workspace但不再静态 import workspace-service;改为 workspace-runtime-bridge 注册运行时能力。
  • sw-capabilities 只需要 WorkZone transforms不需要 WorkZone React 组件;将 transforms 拆到中立模块,with-workzone 保持 re-export 兼容。

经验:服务单例之间需要互相调用时,优先抽一个无副作用 bridge插件的纯 transform/API 不要和 React 渲染组件放在同一个依赖入口。

防回归规则

新增或调整 import 前,先按下面规则判断方向是否正确:

场景 推荐方向 避免方向
类型共享 业务模块 -> types / 纯类型模块 types -> service / UI / registry
元数据读取 常量模块 -> manifests / 纯数据 constants -> registry / 组件加载器
通用 UI 兜底能力 UI -> 窄 helper UI -> 全量业务服务 barrel
插件能力复用 service -> transform-only 模块 service -> plugin React 入口
服务单例互访 service -> runtime bridge service A -> service B -> service A
组件间复用 叶子组件直连叶子组件 叶子组件 -> shared/index 大桶

提交前重点检查三类高风险信号:

  1. 一个 index.ts 同时 re-export 类型、服务单例、组件和注册副作用。
  2. 一个通用组件为了小功能 import 了完整业务 service。
  3. 一个“纯数据/纯类型”文件 import 了 React、registry、storage 或网络请求模块。

如果确实需要跨层调用,先抽窄接口:*-types*-transforms*-bridge*-ref*-helper。这些模块必须保持无 UI、无存储副作用、无注册副作用。

验证清单

基础检查:

NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm check:cycles
NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm check:cycles:types
NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm typecheck

如果改动触及 Vite manual chunk、入口预取或重包懒加载再跑

NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm exec nx build web
NPM_TOKEN=${NPM_TOKEN:-dummy} pnpm verify:startup

后续建议

  1. 新增核心类型时,优先放到纯类型模块,不要顺手放进服务实现文件。
  2. 从 barrel import 前先确认该 barrel 是否包含运行时副作用。
  3. dynamic import SCC 不要和静态环一起治理应按工具窗口、SW、媒体插入等边界拆专题治理时优先切“元数据 -> 注册表”“通用 UI -> 业务服务”“服务单例互访”三类边。
  4. 保持旧导出兼容,但内部依赖逐步迁到低层入口。
  5. 每次拆环都先跑 check:cycles:types,再跑 typecheck,避免只修图不修类型。

一句话结论

循环依赖治理最稳的方式是先分清运行时、类型层和动态懒加载,再用纯类型模块、窄 helper、runtime bridge 打断高风险边界。