前言
依赖混乱:会导致运行时初始化错误、构建缓存失效、单测困难等。本文介绍如何通过双层检测机制(项目级 + 文件级)来治理 Monorepos 中的依赖混乱问题
一、Monorepos 为什么需要依赖治理?
与 Multirepos(多仓库)不同,Monorepos物理隔离性低,依赖容易引用,这既是优势也可能是劣势,优势是依赖引用的便捷性,劣势是依赖管理的复杂性,因为所有项目都在同一目录下,依赖引用容易,引用成本降低。这些便利的特性,在缺少有效管理手段时,会演变成技术债务。
脑海熟悉的语句:“是代码时序问题原因”?
当问题归为一个“时序问题”时,当前的解决方案总是不尽人意。例如:依赖异步延迟引入,调整依赖文件顺序等。这种处理方式,并不能彻底解决依赖中的“时序问题”,有点治标不治本
深挖问题背后,可以归因于“软件架构”上的缺陷,健壮的软件架构里,依赖应是明确的、单向的、分层的。但如是失控的Monorepos中,依赖是跨层的、循环的、幽灵的
Monorepos(单体仓库) 演进过程中,一般依赖会经历的几个阶段:
随着项目规模扩大,依赖关系可能会逐渐失控,项目质量稳定面临挑战。如:
- 项目间依赖随意引用,缺乏边界管控
- 循环依赖越来越多
- 项目间的数据流不再是单向的
- 分层架构中,同层项目彼此耦合
- 可发布的npm项目依赖无法正确安装/构建
二、Monorepos 中的依赖问题
循环依赖(Circular Dependency) 是指两个或多个模块相互依赖,形成"环"。
例如:@scope/lib-a 引用 @scope/lib-b,而 @scope/lib-b 也引用 @scope/lib-a。
参考:Wikipedia - Circular dependency
危害
运行时初始化错误示例:
问题原因:模块加载顺序不确定(竞态条件),config 未初始化就被访问。生产环境会抛出 Cannot read property "X" of undefined。
原因
循环依赖本质上是架构设计缺陷,违反了两个核心原则:
- 分层架构原则:上层依赖下层,下层不应反向依赖上层
- 单向数据流原则:依赖方向应该是单向的,不应形成环
除了循环依赖,Monorepos 中还常见以下问题:
- 幽灵依赖:未在 package.json 中声明但可用的依赖
- 传递依赖滥用:通过间接依赖使用未声明的包
- 跨层依赖:违反分层架构的依赖方向
- 边界模糊:项目间缺乏明确的依赖边界
三、依赖治理方案
治理循环依赖需要建立双层检测 的完整的检测机制
项目级循环依赖甚为严重,需要在项目之间建立明确的能力边界。
四、项目依赖检测实现
边界检测的规则:校验每个文件的 import/export 语句是否满足边界规则
要覆盖检测导入场景,需拦截四类 AST(抽象语法树)节点:
通过自定义插件 EMB,对每个文件执行上述四类校验
ProjectGraph 是 Nx 提供的核心数据结构,依赖关系的来源,由Nx Daemon 检测进程更新,包含三个关键字段:
1. nodes:项目元数据
所有内部项目的元数据:每个节点包含:项目名称、类型(app/lib)、根路径、标签(tags)、构建目标等元数据
2. dependencies:项目依赖关系
记录项目之间的依赖关系,每个项目对应一个 ProjectGraphDependency[] 数组,用于构建依赖图、检测循环依赖,检测构建顺序,优化构建顺序(拓扑排序)等
依赖类型:
"static":静态导入(import x from 'y')
"dynamic":动态导入(await import('y'))
"implicit":隐式依赖(配置文件声明)
3. externalNodes:外部 npm 依赖元数据
存储外部依赖(npm 包)的元数据
依托Nx的开箱即用项目依赖图数据,插件对每个文件执行以下检查步骤:
确定当前文件归属的项目
首先需要识别当前文件归属项目,这是后续检查的前提。依据文件名通过依赖图ProjectGraph.nodes中的依赖映射表,确认该文件所属项目,如果文件路径无法匹配到任务项目,则跳过检查。
确定文件所属项目后,解析文件中每个导入语句的目标依赖。导入语句可能指向三种类型的目标:相对路径导入、绝对路径导入、以及通过scope的包导入。
在正式执行规则检查之前,插件会先检查当前导入是否匹配白名单配置,可灵活配置
依次执行规则检查
确定了当前文件归属项目、目标依赖的归属项目后,按照预定义的优先级依次执行规则检查。验证项目是否符合基于标签的依赖约束。一旦某条规则失败,立即报告错误并终止检查
检测流程图
循环依赖检测算法
项目级依赖检测的算法,用于判断添加依赖后是否会形成循环。
问题:当项目 A 导入项目 B 时,如何判断是否形成循环?
检测思路:检查是否存在从 B 回到 A 的路径。如果存在,则形成循环 A → B → ... → A。
示例代码:
当检测到 A 导入 B 时:
- 如果 B 能反向到达 A:存在循环依赖
A → B → … → A
- 如果 B 无法到达 A:没有循环依赖
一句话概括:检测循环依赖 = 检查是否存在从 B 回到 A 的路径
基于反向邻接表的路径查找算法
邻接表(Adjacency List) 是一种图数据结构,用于表示图中节点之间的连接关系。在依赖关系图中,每个节点代表一个项目,每条边代表一个依赖关系。
正向邻接表
正向邻接表记录的是从当前节点出发可以到达哪些节点,即"我依赖谁":
用数据结构表示就是:
反向邻接表
反向邻接表记录的是哪些节点可以到达当前节点,即"谁依赖我":
用数据结构表示就是:
为什么需要反向邻接表?
在检测循环依赖时,我们需要回答的问题是:"如果 A 依赖 B,那么 B 能否通过某种路径回到 A?"
- 使用正向邻接表:只能知道 B 依赖哪些项目,无法快速知道哪些项目依赖 B
- 使用反向邻接表:可以直接知道哪些项目依赖 B,然后递归查找这些项目的反向依赖,从而快速判断是否存在回到 A 的路径
例如,要检查 A → B 是否形成循环:
- 查找 B 的反向邻接表,得到
[A]
- 发现 A 在列表中,说明存在
B → A 的反向路径
- 结合
A → B,形成循环 A → B → A
项目依赖图开箱即用提供 ProjectGraph.dependencies 项目原始依赖的关系:
上述数据表示的依赖关系:
问题:原始数据记录的是 source → target,但检测循环需要查找 target → source(反向路径)。
解决方案:构建反向邻接表。
有了反向邻接表,就可以通过 BFS(广度优先搜索)或 DFS(深度优先搜索)查找反向路径。
From ChatGPT
interface ProjectNode {
name: string;
type: 'lib' | 'app';
data: {
root: string;
tags?: string[];
projectType?: 'library' | 'application';
};
}
interface Dependency {
source: string;
target: string;
type: 'static' | 'dynamic';
}
interface ProjectGraph {
nodes: Record<string, ProjectNode>;
dependencies: Record<string, Dependency[]>;
}
interface DependencyGraph {
nodes: Map<string, ProjectNode>;
edges: Map<string, string[]>;
}
function buildGraph(projectGraph: ProjectGraph): DependencyGraph {
const nodes = new Map<string, ProjectNode>();
const edges = new Map<string, string[]>();
for (const [name, node] of Object.entries(projectGraph.nodes)) {
nodes.set(name, node);
edges.set(name, []);
}
for (const [source, deps] of Object.entries(projectGraph.dependencies)) {
const internalDeps = deps
.filter(dep => !dep.target.startsWith('npm:'))
.filter(dep => nodes.has(dep.target))
.map(dep => dep.target);
edges.set(source, internalDeps);
}
return { nodes, edges };
}
interface GraphReachabilityCache {
graphHash: string;
matrix: Map<string, Set<string>>;
adjList: Map<string, string[]>;
}
function buildReachabilityMatrix(graph: DependencyGraph): GraphReachabilityCache {
const nodeIds = Array.from(graph.nodes.keys());
const matrix = new Map<string, Set<string>>();
const adjList = graph.edges;
for (const nodeId of nodeIds) {
matrix.set(nodeId, new Set());
}
const dfs = (sourceId: string, currentId: string) => {
const reachable = matrix.get(sourceId);
if (!reachable || reachable.has(currentId)) return;
reachable.add(currentId);
for (const neighbor of adjList.get(currentId) || []) {
dfs(sourceId, neighbor);
}
};
for (const nodeId of nodeIds) {
dfs(nodeId, nodeId);
}
return {
graphHash: Date.now().toString(),
matrix,
adjList,
};
}
interface CycleResult {
hasCycle: boolean;
path: string[];
readablePath: string;
}
function detectCycleDependency(
graph: DependencyGraph,
fromProject: string,
toProject: string,
cache?: GraphReachabilityCache,
): CycleResult {
if (!graph.nodes.has(fromProject) || !graph.nodes.has(toProject)) {
return { hasCycle: false, path: [], readablePath: '' };
}
if (fromProject === toProject) {
return {
hasCycle: true,
path: [fromProject],
readablePath: `${fromProject} → ${fromProject} (自引用)`,
};
}
const { matrix, adjList } = cache || buildReachabilityMatrix(graph);
const canReachBack = matrix.get(toProject)?.has(fromProject);
if (!canReachBack) {
return { hasCycle: false, path: [], readablePath: '' };
}
const path = findPathWithPruning(toProject, fromProject, matrix, adjList);
if (path.length === 0) {
return { hasCycle: false, path: [], readablePath: '' };
}
const cyclePath = [fromProject, ...path];
const readablePath = cyclePath.join(' → ');
return {
hasCycle: true,
path: cyclePath,
readablePath,
};
}
function findPathWithPruning(
sourceId: string,
targetId: string,
matrix: Map<string, Set<string>>,
adjList: Map<string, string[]>,
): string[] {
if (sourceId === targetId) return [sourceId];
const queue: Array<[string, string[]]> = [[sourceId, [sourceId]]];
const visited = new Set<string>([sourceId]);
while (queue.length > 0) {
const item = queue.shift();
if (!item) break;
const [currentId, path] = item;
if (currentId === targetId) {
return path;
}
for (const neighborId of adjList.get(currentId) || []) {
if (visited.has(neighborId)) continue;
if (!matrix.get(neighborId)?.has(targetId)) continue;
visited.add(neighborId);
queue.push([neighborId, [...path, neighborId]]);
}
}
return [];
}
function findAllCycles(graph: DependencyGraph): CycleResult[] {
const cache = buildReachabilityMatrix(graph);
const cycles: CycleResult[] = [];
const processedPairs = new Set<string>();
for (const [source, targets] of graph.edges) {
for (const target of targets) {
const pairKey = [source, target].toSorted().join('|');
if (processedPairs.has(pairKey)) continue;
const result = detectCycleDependency(graph, source, target, cache);
if (result.hasCycle) {
cycles.push(result);
processedPairs.add(pairKey);
}
}
}
return cycles;
}
function formatCycleReport(cycles: CycleResult[], graph: DependencyGraph): string {
if (cycles.length === 0) {
return '✅ 未发现循环依赖';
}
const lines = [`❌ 发现 ${cycles.length} 个循环依赖:\n`];
cycles.forEach((cycle, index) => {
lines.push(`${index + 1}. ${cycle.readablePath}`);
const details = cycle.path.map(name => {
const node = graph.nodes.get(name);
return ` - ${name} (${node?.type || 'unknown'}) @ ${node?.data.root || 'unknown'}`;
});
lines.push(...details);
lines.push('');
});
return lines.join('\n');
}
const mockProjectGraph: ProjectGraph = {
nodes: {
'electron-main': {
name: 'electron-main',
type: 'app',
data: { root: 'packages/electron/electron-main', tags: ['electron:app'] },
},
'electron-renderer': {
name: 'electron-renderer',
type: 'app',
data: { root: 'packages/electron/electron-renderer', tags: ['electron:web'] },
},
'shared-utils': {
name: 'shared-utils',
type: 'lib',
data: { root: 'packages/shared/utils', tags: ['scope:shared'] },
},
'shared-components': {
name: 'shared-components',
type: 'lib',
data: { root: 'packages/shared/components', tags: ['scope:shared'] },
},
foundation: {
name: 'foundation',
type: 'lib',
data: { root: 'packages/foundation', tags: ['scope:core'] },
},
},
dependencies: {
'electron-main': [
{ source: 'electron-main', target: 'foundation', type: 'static' },
{ source: 'electron-main', target: 'npm:electron', type: 'static' },
],
'electron-renderer': [
{ source: 'electron-renderer', target: 'shared-components', type: 'static' },
{ source: 'electron-renderer', target: 'shared-utils', type: 'static' },
],
'shared-components': [
{ source: 'shared-components', target: 'shared-utils', type: 'static' },
{ source: 'shared-components', target: 'electron-renderer', type: 'static' },
],
'shared-utils': [{ source: 'shared-utils', target: 'foundation', type: 'static' }],
foundation: [],
},
};
const depGraph = buildGraph(mockProjectGraph);
const reachabilityCache = buildReachabilityMatrix(depGraph);
console.log('=== 检测 electron-renderer → shared-components ===');
const result1 = detectCycleDependency(
depGraph,
'electron-renderer',
'shared-components',
reachabilityCache,
);
console.log(result1);
console.log('\n=== 检测 foundation → shared-utils 是否安全 ===');
const result2 = detectCycleDependency(depGraph, 'foundation', 'shared-utils', reachabilityCache);
console.log(result2);
console.log('\n=== 扫描所有循环依赖 ===');
const allCycles = findAllCycles(depGraph);
console.log(formatCycleReport(allCycles, depGraph));
五、规则详情
规则可以按功能分为五类:
确保使用正确的导入方式,避免路径硬编码和绕过模块系统。
规则 1:noRelativeOrAbsoluteImportsAcrossLibraries
禁止用相对/绝对路径跨库导入
危害:
- 相对路径跨库会破坏封装,绕过库的公开 API
- 绝对路径会导致路径硬编码,换环境会出错
- 强制使用 scope 导入,让库的内部实现对外隐藏
规则 2:noRelativeOrAbsoluteExternals
禁止用相对/绝对路径导入外部资源
规则 3:noSelfCircularDependencies
禁止通过 scope 导入同项目内的文件
危害:
- 同项目内用 scope 导入会绕过模块解析,可能导致循环引用
- 相对路径更清晰地表达"内部依赖"
- 构建工具处理更高效
这类规则防止项目间和项目内的循环依赖,确保依赖关系的单向性。
规则 4:noCircularDependencies
禁止项目间循环依赖
危害:
- 构建顺序无法确定
- 运行时可能出现未定义/空对象
- 破坏单向依赖流,架构混乱
这类规则约束不同项目类型之间的依赖关系,确保架构层次清晰。
规则 5:noImportsOfApps
禁止导入 app 类型项目
危害:
- App 是最终产物,应该消费库,而不是被消费
- App 包含启动逻辑、环境配置等不可复用代码
- 导入 app 会导致依赖方向反转
规则 6:noImportsOfE2e
禁止导入 e2e(端到端测试)项目
危害:
- E2E 项目是测试代码,不应被业务代码依赖
- E2E 依赖测试框架(Cypress/Playwright),会污染生产构建
- 测试代码和业务代码应严格隔离
规则 7:noImportOfNonBuildableLibraries
可构建库不能依赖不可构建库
危害:
- 可构建库需要独立发布(如发 npm 包)
- 依赖不可构建库意味着无法正确打包
- 确保发布链路完整
规则 8:noImportsOfLazyLoadedLibraries
禁止静态导入懒加载库
危害:
- 懒加载的目的是代码分割,减少首屏体积
- 静态导入会让懒加载库被打入主包,分包策略失效
这类规则基于标签实现分层架构和领域隔离,是依赖治理的核心机制。
无匹配约束标签的项目不能有依赖
危害:
- 配置
depConstraints 后,要求每个项目必须有标签
- 强制团队为项目分类(
scope:shared、type:feature 等)
- 防止漏配标签导致约束规则形同虚设
违反 onlyDependOnLibsWithTags 约束
危害:
- 实现分层架构(feature → data → util)
- 防止平级依赖导致耦合
- 保持依赖方向单向流动
onlyDependOnLibsWithTags 为空但目标有标签
危害:
- 某些项目完全不允许有外部库依赖
- 用于隔离层或纯工具项目
- 空数组表示"只能依赖无标签的项目"
违反 notDependOnLibsWithTags 约束
危害:
- 防止跨边界依赖(前端代码依赖后端代码)
- 实现领域隔离(不同业务域不互相依赖)
- 比
onlyDependOnLibsWithTags 更灵活,是黑名单机制
这类规则控制外部依赖的使用和传递,防止幽灵依赖和依赖滥用。
规则 13:bannedExternalImportsViolation
违反外部依赖禁用规则
危害:
- 某些外部包可能不适合特定层
- 控制包体积(禁止重量级依赖)
- 统一技术栈,防止同类库泛滥
规则 14:nestedBannedExternalImportsViolation
嵌套依赖中存在被禁用的外部包
危害:
- 即使不是直接依赖,间接引入也可能违反架构约定
- 启用
checkNestedExternalImports 后进行深度检查
规则 15:noTransitiveDependencies
禁止传递/未声明的依赖
什么是传递依赖?
传递依赖 = 依赖的依赖(间接依赖)
为什么传递依赖有风险?
由于 npm/yarn/pnpm 的依赖提升机制,传递依赖可能被提升到 node_modules 根目录,导致代码"意外可用":
三大风险:
六、文件级循环检测:import/no-cycle
即使项目间没有循环依赖,项目内部的文件之间仍可能形成循环依赖。并且要求文件级检测的粒度更细,用于检测同一个项目内部文件之间的循环依赖
检测思路
问题:如何判断文件 A 导入文件 B 后,是否存在从 B 回到 A 的路径?
答案:使用**深度优先搜索(DFS)**遍历模块依赖图,检查是否能够回到起始模块。
算法流程
以 oxc 的实现为例,核心检测逻辑如下:
在 DFS 遍历过程中,如果当前文件的路径等于起始文件路径,则发现循环依赖。
例如
配置选项说明
配置建议
- 大型项目:设置
maxDepth: 10-15,避免深层遍历
- 小型项目:可以使用默认值或
maxDepth: 5
- Monorepo:建议
ignoreExternal: true,只检测工作区内部文件
- TypeScript 项目:必须设置
ignoreTypes: true,避免类型导入误报
总结
如果你也在为项目中混乱依赖困扰,不妨开始建立依赖约束方式:
- 项目级检测:自定义 ESLint/Oxlint 插件
- 文件级检测:配置
import/no-cycle 规则,检测项目内文件的循环依赖
两者配合,在CI/CD中持续监控