收口为什么要再看一遍依赖图
单章实验里,模块关系是“这一章 import 上一章”,看起来很清楚。到了卷一收口,九个目录同时存在,引用关系变成一张网:组合根依赖四个模块,视图依赖状态,数据层彼此独立。人脑能记住两三个文件的关系,记不住整张图。
依赖图的第一价值不是画得好看,而是把“允许的边”变成可执行判断。循环依赖、反向依赖、悬空引用这三类问题,单看某个文件都不明显,放进全图就一目了然。第二价值是给改动定位:一个模块被多少模块依赖,决定了改它的接口要通知多少人。
4.2 已经给过一套通用算法。这一节不重复讲原理,而是把它落到卷一的真实模块清单上,并补上“汇总输出”这一层:模块数、边数、构建顺序、被依赖热点、阻塞模块。它仍然独立可运行,不 import 4.2 的实现。
图从构建清单来
图中的节点不是文件路径的字符串匹配,而是扫描器产出的模块记录:模块 id、所属层、静态 import 列表。扫描器可以是构建脚本,也可以是一次性的仓库检查;本节的函数只吃清单,不读文件系统。
这样做有两个好处。第一,测试不需要造真实目录,三五个模块就能覆盖循环与反向。第二,同一个函数可以接不同来源:TypeScript 的 import 图、打包器的模块图、甚至手工维护的子系统清单。输入统一,判断就统一。
清单里的层沿用前文的划分,并新增一个 summary 层:
| 层 | 等级 | 说明 |
|---|---|---|
| summary | 4 | 卷一组合根,只允许出现在最外层 |
| view | 3 | 页面与组件契约 |
| state | 2 | 共享状态、状态机、缓存视图 |
| domain | 1 | 业务规则与用例 |
| data | 0 | 缓存、队列、调度器等基础设施 |
| shared | -1 | 纯工具,不依赖任何业务层 |
规则仍是“目标等级不高于来源等级”。同层允许,shared 只能依赖自己。summary 之所以单独设一层,是因为组合根本来就要同时认识页面栈、状态与数据层;如果把它塞进 view,summary -> data 会被误报成跨层,团队就会开始加例外,最后例外比规则还多。
构建、校验与汇总
下面的文件一次产出五类结果:规范化后的边、方向违规、悬空引用、构建顺序、循环回路。任何一个非空,结论就是 fail。
// file: src/summary/module-dependency.ts
// tier: L1
// verify: node --test test/summary/module-dependency.test.ts
export type GraphLayer = "summary" | "view" | "state" | "domain" | "data" | "shared";
export interface ModuleRecord {
id: string;
layer: GraphLayer;
/** 本模块静态依赖的模块 id */
imports: string[];
}
export interface GraphEdge {
from: string;
to: string;
}
export interface DirectionViolation {
from: string;
to: string;
message: string;
}
export interface DependencyReport {
modules: string[];
edges: GraphEdge[];
cycles: string[][];
directionViolations: DirectionViolation[];
missing: string[];
fanIn: Record<string, number>;
fanOut: Record<string, number>;
order: string[];
blocked: string[];
status: "pass" | "fail";
}
/** 数字越大越靠外;依赖只允许从大到小,summary 是卷一的组合根。 */
export const LAYER_RANK: Record<GraphLayer, number> = {
summary: 4,
view: 3,
state: 2,
domain: 1,
data: 0,
shared: -1,
};
interface Normalized {
names: string[];
layers: Map<string, GraphLayer>;
adj: Map<string, string[]>;
}
function normalize(modules: ModuleRecord[]): Normalized {
const names: string[] = [];
const layers = new Map<string, GraphLayer>();
for (const module of modules) {
if (layers.has(module.id)) throw new Error(`重复模块: ${module.id}`);
layers.set(module.id, module.layer);
names.push(module.id);
}
names.sort();
const adj = new Map<string, string[]>();
for (const name of names) adj.set(name, []);
for (const module of modules) {
const targets = [...new Set(module.imports)].sort();
adj.set(module.id, targets);
}
return { names, layers, adj };
}
/** 在 blocked 集合内找一条回到 start 的具体回路,找不到返回 undefined。 */
function cycleFrom(
start: string,
adj: Map<string, string[]>,
allowed: Set<string>,
): string[] | undefined {
const path: string[] = [];
const onPath = new Set<string>();
const walk = (node: string): string[] | undefined => {
path.push(node);
onPath.add(node);
for (const next of adj.get(node) ?? []) {
if (next === start) return [...path, start];
if (!allowed.has(next) || onPath.has(next)) continue;
const found = walk(next);
if (found) return found;
}
path.pop();
onPath.delete(node);
return undefined;
};
return walk(start);
}
/**
* 从一张模块清单构建依赖图,并一次产出循环、方向、悬空引用与构建顺序。
* 不读文件系统:清单由构建脚本扫描后传入,纯函数可在测试里完整覆盖。
*/
export function buildDependencyReport(modules: ModuleRecord[]): DependencyReport {
const { names, layers, adj } = normalize(modules);
const known = new Set(names);
const edges: GraphEdge[] = [];
const missing = new Set<string>();
for (const from of names) {
for (const to of adj.get(from) ?? []) {
edges.push({ from, to });
if (!known.has(to)) missing.add(to);
}
}
const directionViolations: DirectionViolation[] = [];
for (const edge of edges) {
const fromLayer = layers.get(edge.from) as GraphLayer;
const toLayer = layers.get(edge.to);
if (!toLayer) continue;
if (LAYER_RANK[toLayer] > LAYER_RANK[fromLayer]) {
directionViolations.push({
from: edge.from,
to: edge.to,
message: `${edge.from}(${fromLayer}) 依赖了更外层的 ${edge.to}(${toLayer})`,
});
}
}
directionViolations.sort(
(a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to),
);
const indegree = new Map<string, number>();
const dependents = new Map<string, string[]>();
for (const name of names) {
indegree.set(name, 0);
dependents.set(name, []);
}
for (const edge of edges) {
if (!known.has(edge.to)) continue;
indegree.set(edge.from, (indegree.get(edge.from) ?? 0) + 1);
dependents.get(edge.to)?.push(edge.from);
}
const ready = names.filter((name) => indegree.get(name) === 0).sort();
const order: string[] = [];
while (ready.length > 0) {
const node = ready.shift() as string;
order.push(node);
for (const dependent of dependents.get(node) ?? []) {
const left = (indegree.get(dependent) ?? 0) - 1;
indegree.set(dependent, left);
if (left === 0) ready.push(dependent);
}
ready.sort();
}
const emitted = new Set(order);
const blocked = names.filter((name) => !emitted.has(name));
const blockedSet = new Set(blocked);
const cycles: string[][] = [];
const covered = new Set<string>();
for (const start of blocked) {
if (covered.has(start)) continue;
const path = cycleFrom(start, adj, blockedSet);
if (!path) continue;
cycles.push(path);
for (const node of path) covered.add(node);
}
cycles.sort((a, b) => a.join(">").localeCompare(b.join(">")));
const fanIn: Record<string, number> = {};
const fanOut: Record<string, number> = {};
for (const name of names) {
fanIn[name] = 0;
fanOut[name] = (adj.get(name) ?? []).length;
}
for (const edge of edges) {
if (known.has(edge.to)) fanIn[edge.to] = (fanIn[edge.to] ?? 0) + 1;
}
const status =
cycles.length === 0 &&
directionViolations.length === 0 &&
missing.size === 0
? "pass"
: "fail";
return {
modules: names,
edges,
cycles,
directionViolations,
missing: [...missing].sort(),
fanIn,
fanOut,
order,
blocked,
status,
};
}
/** 输出稳定文本,便于贴进 CI 日志或基线评审记录。 */
export function formatDependencyReport(report: DependencyReport): string[] {
const lines = [
`已检查 ${report.modules.length} 个模块,${report.edges.length} 条依赖`,
`构建顺序:${report.order.join(" -> ") || "无"}`,
];
const topFanIn = Object.entries(report.fanIn)
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.slice(0, 3);
lines.push(
`被依赖最多:${
topFanIn.map(([id, count]) => `${id}(${count})`).join(", ") || "无"
}`,
);
for (const cycle of report.cycles) lines.push(`循环依赖:${cycle.join(" -> ")}`);
for (const violation of report.directionViolations) {
lines.push(`方向违规:${violation.message}`);
}
for (const id of report.missing) lines.push(`悬空引用:${id}`);
if (report.status === "pass") lines.push("结果:pass");
else lines.push(`结果:fail(阻塞 ${report.blocked.length} 个模块)`);
return lines;
}
实现里有四个取舍。
第一,import 先去重再排序。同一模块重复写两次 import 不会产生两条边,否则 fanIn 会被重复计数,评审时看不出真实热度。
第二,拓扑排序用 Kahn 而不是递归 DFS。Kahn 的好处是“排不完的节点”天然就是阻塞集合,不需要额外的访问标记;blocked 直接告诉评审者哪些模块因为环而无法确定构建顺序。
第三,回路只在阻塞集合里找。一个节点依赖了环,自己也会排不出来,但它不在环上;从它出发找不到回到自身的路径,函数会跳过它,继续找真正的环。这样输出的 cycles 是具体回路,不是一坨阻塞节点。
第四,方向检查跳过悬空目标。目标模块不存在时,报“悬空引用”已经足够;再报一条方向违规会让同一个问题出现两次,修复者反而不知道先改哪个。
formatDependencyReport() 把结果压成固定文本:第一行是规模,第二行是构建顺序,第三行是被依赖最多的三个模块,之后是循环、方向、悬空,最后一行是结论。固定格式的意义是可比较:两次评审的记录可以直接 diff,而不是靠人读两段自由文本。
测试与真实输出
测试用一张卷一的真实依赖图,加三张故意坏掉的图:同层三角环、数据层反向依赖视图层、引用不存在的模块。完整测试在 test/summary/module-dependency.test.ts。
单文件命令:
node --test test/summary/module-dependency.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 卷一真实依赖图无循环、无反向、无悬空引用 (0.996458ms)
✔ 同层三角依赖被 Kahn 拓扑识别为循环并给出一条回路 (0.1925ms)
✔ 数据层反向依赖视图层会被判为方向违规 (0.153542ms)
✔ 引用清单里不存在的模块会进入 missing 并让结果失败 (0.097333ms)
✔ 文本输出包含顺序、热点与结论,可直接贴进评审记录 (9.100459ms)
ℹ tests 5
ℹ suites 0
ℹ pass 5
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 158.524333
循环测试特别值得保留。三个同层模块互相 import,方向检查完全通过,只有拓扑排序能发现它们无法排出先后。这正是“方向对但结构坏”的典型:每条边都合法,整体却不可构建。只做方向检查的团队会漏掉这类问题。
卷一依赖图的实际结论
把前十二章的模块按真实 import 汇总,得到的核心结论是:
| 模块 | 层 | fanIn | fanOut |
|---|---|---|---|
data/cache-store | data | 1 | 0 |
data/sync-queue | data | 1 | 0 |
router/page-stack | view | 1 | 0 |
state/store | state | 2 | 0 |
view/contract | view | 0 | 1 |
summary/baseline | summary | 0 | 4 |
整张图 6 个模块、5 条边,没有循环、没有反向、没有悬空引用。数据层的两个模块 fanOut 为 0,说明它们可以独立测试;state/store 的 fanIn 为 2,说明改它的接口至少要通知视图契约与组合根两个地方;summary/baseline 的 fanOut 为 4,是整张图里唯一的宽依赖点。
宽依赖点不是坏事,但必须唯一。组合根如果分裂成三个各自 import 一半模块的入口,失败语义就会被复制三份,改一处漏两处。卷一把它收敛到一个 EchoBaseline,就是为了让“先缓存后网络、失败标离线、写入先落盘”只存在一份。
同样要看到图里没有的东西:data/cache-store 与 data/sync-queue 之间没有边,它们通过组合根协作,而不是互相调用。这是有意的。缓存不需要知道队列存在,队列也不需要知道缓存怎么失效;一旦它们直接互相 import,数据层内部就会长出一个新的隐式组合根。
依赖图不是架构图
依赖图只回答“谁 import 谁”,不回答“运行时谁调用谁”。一次动态导入可以没有静态边,一次反射式注册可以制造图上看不见的耦合;反过来,只 import 类型的边不产生运行时代码,但它仍然影响编译与变更成本。
它也不回答“这个依赖是否合理”。summary -> data 在等级上合法,在职责上是否应该存在,要看组合根有没有把业务规则偷偷搬进来。13.1 的基线把顺序与失败语义放在组合根,把算法留在数据层,这个分工无法由依赖图证明,只能由代码评审判断。
所以依赖图是门禁,不是判决书。它能拦住循环、反向与悬空,不能替代 4.1 的调用审计、4.3 的架构规则和人的职责判断。三层证据合在一起,才足以说“模块划分成立”。
小结
卷一收口时,依赖图把散落在各章的 import 关系变成一张可校验的图:节点带层,边带方向,拓扑排序给出构建顺序,回路检测找出“方向都对但结构坏”的环。
卷一的真实图很小,结论却明确:数据层是叶子,状态是热点,组合根是唯一宽依赖点。保持这个形状,改动的传播范围就可预测;破坏这个形状,最先出现的往往不是编译错误,而是失败语义被复制。
阅读导航:上一节:13.1 回声客户端基线 · 下一节:13.3 质量属性场景与风险清单 。
常见错误与边界
- 把依赖图当成架构图。它只看 import,不看运行时调用与职责。
- 只检查方向,不检查循环。每条边都合法、整体却排不出顺序的情况很常见。
- 把
summary塞进view。组合根向下穿透多层是设计,不是违规。 - 让组合根分裂成多个入口。失败语义会被复制,改一处漏两处。
- 把悬空引用和方向违规同时报。同一个问题报两次,修复者无法排序。
- 用重复 import 抬高 fanIn。先去重再统计,否则热点是假的。
- 把类型 import 当成不存在。它不影响运行时,但影响编译与变更成本。
- 依赖图通过就宣布架构通过。调用审计、职责判断与质量场景仍要单独做。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。