Monorepos 依赖治理:从混乱到有序的工程化实践 - 废材壶 BLOG
    Monorepos 依赖治理:从混乱到有序的工程化实践
    lint 插件实现项目间依赖边界管控
    12 December, 20254,82820 分钟

    介绍通过自定义 lint 插件检测并治理 Monorepos 中的依赖问题

    前言

    依赖混乱:会导致运行时初始化错误、构建缓存失效、单测困难等。本文介绍如何通过双层检测机制(项目级 + 文件级)来治理 Monorepos 中的依赖混乱问题

    一、Monorepos 为什么需要依赖治理?

    Multirepos(多仓库)不同,Monorepos物理隔离性低,依赖容易引用,这既是优势也可能是劣势,优势是依赖引用的便捷性,劣势是依赖管理的复杂性,因为所有项目都在同一目录下,依赖引用容易,引用成本降低。这些便利的特性,在缺少有效管理手段时,会演变成技术债务。

    脑海熟悉的语句:“是代码时序问题原因”?

    当问题归为一个“时序问题”时,当前的解决方案总是不尽人意。例如:依赖异步延迟引入,调整依赖文件顺序等。这种处理方式,并不能彻底解决依赖中的“时序问题”,有点治标不治本

    深挖问题背后,可以归因于“软件架构”上的缺陷,健壮的软件架构里,依赖应是明确的、单向的、分层的。但如是失控的Monorepos中,依赖是跨层的、循环的、幽灵的

    Monorepos(单体仓库) 演进过程中,一般依赖会经历的几个阶段:

    随着项目规模扩大,依赖关系可能会逐渐失控,项目质量稳定面临挑战。如:

    • 项目间依赖随意引用,缺乏边界管控
    • 循环依赖越来越多
    • 项目间的数据流不再是单向的
    • 分层架构中,同层项目彼此耦合
    • 可发布的npm项目依赖无法正确安装/构建

    二、Monorepos 中的依赖问题

    2.1 循环依赖

    循环依赖(Circular Dependency) 是指两个或多个模块相互依赖,形成"环"。

    例如:@scope/lib-a 引用 @scope/lib-b,而 @scope/lib-b 也引用 @scope/lib-a

    参考:Wikipedia - Circular dependency

    危害

    危害说明
    Tree-Shaking 失效编译器无法正确分析依赖关系,导致死代码无法剔除
    增量构建缓存失效循环链上的任一模块变更,整条链都需要重新构建
    HMR 热更新异常Hot Module Replacement(热模块替换)可能失效或行为异常
    单元测试隔离性降低难以单独测试循环依赖中的某个模块
    可维护性下降代码耦合严重,修改一处影响多处
    运行时初始化错误模块加载顺序不确定,导致变量未定义

    运行时初始化错误示例:

    // lib-a/src/config.js
    import { init as initLibB } from '@scope/lib-b';
    export const config = { featureX: initLibB().isEnabled };
    
    // lib-b/src/index.js
    import { config } from '@scope/lib-a';
    
    export function init() {
      // config 可能为 undefined!
      return { isEnabled: config.featureY }; 
    }
    

    问题原因:模块加载顺序不确定(竞态条件),config 未初始化就被访问。生产环境会抛出 Cannot read property "X" of undefined

    原因

    循环依赖本质上是架构设计缺陷,违反了两个核心原则:

    • 分层架构原则:上层依赖下层,下层不应反向依赖上层
    • 单向数据流原则:依赖方向应该是单向的,不应形成环

    2.2 其他依赖问题

    除了循环依赖,Monorepos 中还常见以下问题:

    • 幽灵依赖:未在 package.json 中声明但可用的依赖
    • 传递依赖滥用:通过间接依赖使用未声明的包
    • 跨层依赖:违反分层架构的依赖方向
    • 边界模糊:项目间缺乏明确的依赖边界

    三、依赖治理方案

    治理循环依赖需要建立双层检测 的完整的检测机制

    检测层级检测工具检测粒度
    项目级检测定义 lint 插件(EMB)跨项目的依赖边界
    文件级检测import/no-cycle同项目内的文件循环

    项目级循环依赖甚为严重,需要在项目之间建立明确的能力边界。

    四、项目依赖检测实现

    4.1 依赖检测原理

    边界检测的规则:校验每个文件的 import/export 语句是否满足边界规则

    4.2 检测场景

    场景代码示例治理要点
    静态 npm 包导入import { uniq } from 'lodash'只能引用 package.json 中声明的依赖
    动态 npm 包导入await import('lodash-es')同样受依赖声明限制
    相对路径导入import Button from '../shared/Button.vue'只能访问同一项目,禁止跨项目
    绝对路径导入import Chart from 'libs/visual/chart'禁止硬编码路径,绕过 scope 管控
    项目级 scope 导入import { Store } from '@scope/state'需通过循环、标签、buildable 等全部检查
    re-export 导出export * from '@scope/lib-x'与 import 走相同检查链路
    动态变量拼接await import(`@scope/${name}`)无法静态解析,需特殊管控

    4.3 检测 AST

    要覆盖检测导入场景,需拦截四类 AST(抽象语法树)节点:

    AST 节点类型对应语法
    ImportDeclaration静态导入:import { x } from 'y'
    ImportExpression动态导入:await import('y')
    ExportAllDeclaration整体导出:export * from 'y'
    ExportNamedDeclaration具名导出:export { x } from 'y'

    通过自定义插件 EMB,对每个文件执行上述四类校验

    4.4 依赖关键数据

    数据来源用途
    ProjectGraphNx 的 readCachedProjectGraph包含所有项目节点、依赖边、外部 npm 节点
    ProjectRootMappingsnodes[*].data.root 构建将文件路径映射回所属项目
    RuleOptions.oxlintrc.json 配置文件控制白名单、标签约束、忽略循环等
    WorkspaceLayoutnx.json判定 appsDir/libsDir,识别绝对路径导入

    4.5 ProjectGraph 数据结构详情

    ProjectGraph 是 Nx 提供的核心数据结构,依赖关系的来源,由Nx Daemon 检测进程更新,包含三个关键字段:

    1. nodes:项目元数据

    所有内部项目的元数据:每个节点包含:项目名称、类型(app/lib)、根路径、标签(tags)、构建目标等元数据

    "nodes": {
     "@myorg/web-app": {
          "name": "@myorg/web-app",
          "type": "app",
          "data": {
            "root": "packages/apps/web-app",
            "name": "@myorg/web-app",
            "projectType": "application",
            "tags": [
              "scope:web"
            ],
            // ...
          }
        },
    }
    

    2. dependencies:项目依赖关系

    记录项目之间的依赖关系,每个项目对应一个 ProjectGraphDependency[] 数组,用于构建依赖图、检测循环依赖,检测构建顺序,优化构建顺序(拓扑排序)等

    依赖类型

    • "static":静态导入(import x from 'y'
    • "dynamic":动态导入(await import('y')
    • "implicit":隐式依赖(配置文件声明)

    3. externalNodes:外部 npm 依赖元数据

    存储外部依赖(npm 包)的元数据

    "externalNodes": {
      "npm:7zip-bin": {
        "type": "npm",
        "name": "npm:7zip-bin",
        "data": {
          "version": "5.2.0",
          "packageName": "7zip-bin",
          "hash": "sha512-..."
        }
      }
    }
    

    4.6 检查执行详细流程

    依托Nx的开箱即用项目依赖图数据,插件对每个文件执行以下检查步骤:

    确定当前文件归属的项目

    首先需要识别当前文件归属项目,这是后续检查的前提。依据文件名通过依赖图ProjectGraph.nodes中的依赖映射表,确认该文件所属项目,如果文件路径无法匹配到任务项目,则跳过检查。

    确定文件所属项目后,解析文件中每个导入语句的目标依赖。导入语句可能指向三种类型的目标:相对路径导入、绝对路径导入、以及通过scope的包导入。

    在正式执行规则检查之前,插件会先检查当前导入是否匹配白名单配置,可灵活配置

    依次执行规则检查

    确定了当前文件归属项目、目标依赖的归属项目后,按照预定义的优先级依次执行规则检查。验证项目是否符合基于标签的依赖约束。一旦某条规则失败,立即报告错误并终止检查

    检测流程图

    循环依赖检测算法

    项目级依赖检测的算法,用于判断添加依赖后是否会形成循环。

    问题:当项目 A 导入项目 B 时,如何判断是否形成循环?

    检测思路:检查是否存在从 B 回到 A 的路径。如果存在,则形成循环 A → B → ... → A

    示例代码

    // 文件 A.ts
    import { some } from 'B';
    

    当检测到 A 导入 B 时:

    • 如果 B 能反向到达 A:存在循环依赖 A → B → … → A
    • 如果 B 无法到达 A:没有循环依赖

    一句话概括:检测循环依赖 = 检查是否存在从 B 回到 A 的路径

    基于反向邻接表的路径查找算法

    邻接表(Adjacency List) 是一种图数据结构,用于表示图中节点之间的连接关系。在依赖关系图中,每个节点代表一个项目,每条边代表一个依赖关系。

    正向邻接表

    正向邻接表记录的是从当前节点出发可以到达哪些节点,即"我依赖谁":

    A[B, C]  (A 依赖 B 和 C)
    B[D]     (B 依赖 D)
    C → []      (C 不依赖任何项目)
    D → []      (D 不依赖任何项目)
    

    用数据结构表示就是:

    {
      "A": ["B", "C"],
      "B": ["D"],
      "C": [],
      "D": []
    }
    

    反向邻接表

    反向邻接表记录的是哪些节点可以到达当前节点,即"谁依赖我":

    A[]      (没有项目依赖 A)
    B[A]     (A 依赖 B)
    C → [A]     (A 依赖 C)
    D → [B]     (B 依赖 D)
    

    用数据结构表示就是:

    {
      "A": [],
      "B": ["A"],
      "C": ["A"],
      "D": ["B"]
    }
    

    为什么需要反向邻接表?

    在检测循环依赖时,我们需要回答的问题是:"如果 A 依赖 B,那么 B 能否通过某种路径回到 A?"

    • 使用正向邻接表:只能知道 B 依赖哪些项目,无法快速知道哪些项目依赖 B
    • 使用反向邻接表:可以直接知道哪些项目依赖 B,然后递归查找这些项目的反向依赖,从而快速判断是否存在回到 A 的路径

    例如,要检查 A → B 是否形成循环:

    1. 查找 B 的反向邻接表,得到 [A]
    2. 发现 A 在列表中,说明存在 B → A 的反向路径
    3. 结合 A → B,形成循环 A → B → A

    项目依赖图开箱即用提供 ProjectGraph.dependencies 项目原始依赖的关系:

    dependencies: {
      "项目A": [
        { source: "A", target: "B", type: "static" },
        { source: "A", target: "C", type: "static" }
      ],
      "项目B": [
        { source: "B", target: "D", type: "static" }
      ]
    }
    

    上述数据表示的依赖关系:

    A → B  (A 依赖 B)
    A → C  (A 依赖 C)
    B → D  (B 依赖 D)
    

    问题:原始数据记录的是 source → target,但检测循环需要查找 target → source(反向路径)。

    解决方案:构建反向邻接表

    B → [A]  (B 被 A 依赖,从 B 可反向到达 A)
    C → [A]  (C 被 A 依赖)
    D → [B]  (D 被 B 依赖)
    

    有了反向邻接表,就可以通过 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[]>;
    }
    
    /**
     * 将ProjectGraph 转换为邻接表形式
     * 注意:只保留内部项目依赖,过滤掉 npm: 开头的外部依赖
     */
    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:')) // 过滤掉 npm 包
          .filter(dep => nodes.has(dep.target)) // 确保目标节点存在
          .map(dep => dep.target);
    
        edges.set(source, internalDeps);
      }
    
      return { nodes, edges };
    }
    
    // ========== 3. 可达性矩阵 ==========
    
    interface GraphReachabilityCache {
      graphHash: string; // 用于缓存失效判断
      matrix: Map<string, Set<string>>; // 使用 Set 更高效
      adjList: Map<string, string[]>;
    }
    
    /**
     * 预构建可达性矩阵
     */
    function buildReachabilityMatrix(graph: DependencyGraph): GraphReachabilityCache {
      const nodeIds = Array.from(graph.nodes.keys());
      // 可达性矩阵
      // matrix[B][A] = true
      const matrix = new Map<string, Set<string>>();
      // 邻接表
      const adjList = graph.edges;
    
      // 初始化
      for (const nodeId of nodeIds) {
        matrix.set(nodeId, new Set());
      }
    
      // DFS 计算传递闭包
      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,
      };
    }
    
    // ========== 4. 循环依赖检测 ==========
    
    interface CycleResult {
      hasCycle: boolean;
      path: string[]; // 循环路径
      readablePath: string; // 可读的路径描述
    }
    
    /**
     * 检测添加依赖后是否会形成循环
     *
     * @param graph 项目依赖图
     * @param fromProject 依赖发起方(即将 import 的项目)
     * @param toProject 被依赖方(被 import 的项目)
     * @param cache 可达性缓存
     */
    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);
    
      // 快速检查:toProject 能否到达 fromProject?
      // 如果能,说明 fromProject → toProject 会形成循环
      const canReachBack = matrix.get(toProject)?.has(fromProject);
    
      if (!canReachBack) {
        return { hasCycle: false, path: [], readablePath: '' };
      }
    
      // 使用 BFS + 剪枝 找出具体路径
      const path = findPathWithPruning(toProject, fromProject, matrix, adjList);
    
      if (path.length === 0) {
        return { hasCycle: false, path: [], readablePath: '' };
      }
    
      // 构建完整循环路径:fromProject → toProject → ... → fromProject
      const cyclePath = [fromProject, ...path];
      const readablePath = cyclePath.join(' → ');
    
      return {
        hasCycle: true,
        path: cyclePath,
        readablePath,
      };
    }
    
    /**
     * BFS + 可达性矩阵剪枝查找路径
     */
    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;
    
          // 剪枝:检查 neighbor 能否到达 target
          if (!matrix.get(neighborId)?.has(targetId)) continue;
    
          visited.add(neighborId);
          queue.push([neighborId, [...path, neighborId]]);
        }
      }
    
      return [];
    }
    
    // ========== 5. 实用工具函数 ==========
    
    /**
     * 扫描整个图中所有的循环依赖
     */
    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');
    }
    
    // ========== 6. 使用示例 ==========
    
    // 模拟项目依赖图数据
    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);
    
    // 场景1:检测现有依赖是否形成循环
    console.log('=== 检测 electron-renderer → shared-components ===');
    const result1 = detectCycleDependency(
      depGraph,
      'electron-renderer',
      'shared-components',
      reachabilityCache,
    );
    console.log(result1);
    
    
    // 场景2:检测添加新依赖是否安全
    console.log('\n=== 检测 foundation → shared-utils 是否安全 ===');
    const result2 = detectCycleDependency(depGraph, 'foundation', 'shared-utils', reachabilityCache);
    console.log(result2);
    
    
    // 场景3:扫描所有循环
    console.log('\n=== 扫描所有循环依赖 ===');
    const allCycles = findAllCycles(depGraph);
    console.log(formatCycleReport(allCycles, depGraph));
    
    

    五、规则详情

    规则可以按功能分为五类:

    分类规则数量作用
    导入路径规则3 条规范导入路径的使用方式
    循环依赖规则2 条防止项目间和项目内的循环依赖
    项目类型规则4 条约束不同项目类型之间的依赖关系
    标签约束规则4 条基于标签实现分层架构和领域隔离
    外部依赖规则3 条控制外部依赖的使用和传递

    规则总览表

    分类规则含义
    导入路径规则noRelativeOrAbsoluteImportsAcrossLibraries禁止用相对/绝对路径跨库导入
    导入路径规则noRelativeOrAbsoluteExternals禁止用相对/绝对路径导入外部资源
    导入路径规则noSelfCircularDependencies禁止通过 scope 导入同项目
    循环依赖规则noCircularDependencies禁止项目间循环依赖
    项目类型规则noImportsOfApps禁止导入 app 类型项目
    项目类型规则noImportsOfE2e禁止导入 e2e(端到端测试)项目
    项目类型规则noImportOfNonBuildableLibraries可构建库不能依赖不可构建库
    项目类型规则noImportsOfLazyLoadedLibraries禁止静态导入懒加载库
    标签约束规则projectWithoutTagsCannotHaveDependencies无匹配标签的项目不能有依赖
    标签约束规则onlyTagsConstraintViolation违反 onlyDependOnLibsWithTags 约束
    标签约束规则emptyOnlyTagsConstraintViolationonlyDependOnLibsWithTags 为空但目标有标签
    标签约束规则notTagsConstraintViolation违反 notDependOnLibsWithTags 约束
    外部依赖规则bannedExternalImportsViolation违反外部依赖禁用规则
    外部依赖规则nestedBannedExternalImportsViolation嵌套依赖中存在被禁用的外部包
    外部依赖规则noTransitiveDependencies禁止传递/未声明的依赖

    5.1 导入路径规则

    确保使用正确的导入方式,避免路径硬编码和绕过模块系统。

    规则 1:noRelativeOrAbsoluteImportsAcrossLibraries

    禁止用相对/绝对路径跨库导入

    // ❌ 错误
    import { foo } from '../../libs/shared';
    
    // ✅ 正确
    import { foo } from '@myorg/shared';
    

    危害

    • 相对路径跨库会破坏封装,绕过库的公开 API
    • 绝对路径会导致路径硬编码,换环境会出错
    • 强制使用 scope 导入,让库的内部实现对外隐藏

    规则 2:noRelativeOrAbsoluteExternals

    禁止用相对/绝对路径导入外部资源

    // ❌ 错误
    import _ from '../../node_modules/lodash';
    import axios from '/home/user/project/node_modules/axios';
    
    // ✅ 正确
    import _ from 'lodash';
    

    规则 3:noSelfCircularDependencies

    禁止通过 scope 导入同项目内的文件

    // 在 @myorg/shared 库内部
    // ❌ 错误
    import { helper } from '@myorg/shared';
    
    // ✅ 正确
    import { helper } from './helper';
    

    危害

    • 同项目内用 scope 导入会绕过模块解析,可能导致循环引用
    • 相对路径更清晰地表达"内部依赖"
    • 构建工具处理更高效

    5.2 循环依赖规则

    这类规则防止项目间和项目内的循环依赖,确保依赖关系的单向性。

    规则 4:noCircularDependencies

    禁止项目间循环依赖

    项目 A → 项目 B → 项目 C → 项目 A

    危害

    • 构建顺序无法确定
    • 运行时可能出现未定义/空对象
    • 破坏单向依赖流,架构混乱

    5.3 项目类型规则

    这类规则约束不同项目类型之间的依赖关系,确保架构层次清晰。

    规则 5:noImportsOfApps

    禁止导入 app 类型项目

    // ❌ 错误(从 lib 导入 app)
    import { AppComponent } from '@myorg/my-app';
    

    危害

    • App 是最终产物,应该消费库,而不是被消费
    • App 包含启动逻辑、环境配置等不可复用代码
    • 导入 app 会导致依赖方向反转

    规则 6:noImportsOfE2e

    禁止导入 e2e(端到端测试)项目

    // ❌ 错误
    import { testUtils } from '@myorg/my-app-e2e';
    

    危害

    • E2E 项目是测试代码,不应被业务代码依赖
    • E2E 依赖测试框架(Cypress/Playwright),会污染生产构建
    • 测试代码和业务代码应严格隔离

    规则 7:noImportOfNonBuildableLibraries

    可构建库不能依赖不可构建库

    buildable-lib → non-buildable-lib

    危害

    • 可构建库需要独立发布(如发 npm 包)
    • 依赖不可构建库意味着无法正确打包
    • 确保发布链路完整

    规则 8:noImportsOfLazyLoadedLibraries

    禁止静态导入懒加载库

    // 假设 feature-dashboard 配置为懒加载
    // ❌ 错误
    import { DashboardModule } from '@myorg/feature-dashboard';
    
    // ✅ 正确(动态导入)
    loadChildren: () => import('@myorg/feature-dashboard')
    

    危害

    • 懒加载的目的是代码分割,减少首屏体积
    • 静态导入会让懒加载库被打入主包,分包策略失效

    5.4 标签约束规则

    这类规则基于标签实现分层架构和领域隔离,是依赖治理的核心机制。

    规则 9:projectWithoutTagsCannotHaveDependencies

    无匹配约束标签的项目不能有依赖

    危害

    • 配置 depConstraints 后,要求每个项目必须有标签
    • 强制团队为项目分类(scope:sharedtype:feature 等)
    • 防止漏配标签导致约束规则形同虚设

    规则 10:onlyTagsConstraintViolation

    违反 onlyDependOnLibsWithTags 约束

    // 配置:type:feature 只能依赖 type:util 或 type:data
    // ❌ 错误:feature 依赖了另一个 feature
    import { OtherFeature } from '@myorg/other-feature';
    

    危害

    • 实现分层架构(feature → data → util)
    • 防止平级依赖导致耦合
    • 保持依赖方向单向流动

    规则 11:emptyOnlyTagsConstraintViolation

    onlyDependOnLibsWithTags 为空但目标有标签

    // 配置:onlyDependOnLibsWithTags: []
    // ❌ 目标库有标签
    

    危害

    • 某些项目完全不允许有外部库依赖
    • 用于隔离层纯工具项目
    • 空数组表示"只能依赖无标签的项目"

    规则 12:notTagsConstraintViolation

    违反 notDependOnLibsWithTags 约束

    // 配置:scope:client 不能依赖 scope:server
    // ❌ 错误
    import { serverUtil } from '@myorg/server-utils';
    

    危害

    • 防止跨边界依赖(前端代码依赖后端代码)
    • 实现领域隔离(不同业务域不互相依赖)
    • onlyDependOnLibsWithTags 更灵活,是黑名单机制

    5.5 外部依赖规则

    这类规则控制外部依赖的使用和传递,防止幽灵依赖和依赖滥用。

    规则 13:bannedExternalImportsViolation

    违反外部依赖禁用规则

    // 配置:type:util 的项目禁止导入 lodash
    // ❌ 错误
    import _ from 'lodash';
    

    危害

    • 某些外部包可能不适合特定层
    • 控制包体积(禁止重量级依赖)
    • 统一技术栈,防止同类库泛滥

    规则 14:nestedBannedExternalImportsViolation

    嵌套依赖中存在被禁用的外部包

    项目 A → 项目 B → lodash(被禁用)❌
    

    危害

    • 即使不是直接依赖,间接引入也可能违反架构约定
    • 启用 checkNestedExternalImports 后进行深度检查

    规则 15:noTransitiveDependencies

    禁止传递/未声明的依赖

    // 项目 A 的 package.json 没声明 lodash
    // 但 A 依赖的 B 有 lodash
    // ❌ 错误
    import _ from 'lodash';
    

    什么是传递依赖?

    传递依赖 = 依赖的依赖(间接依赖)

    项目 A → 项目 B → lodash
        ↑         ↑
      直接依赖   传递依赖(对 A 来说)
    

    为什么传递依赖有风险?

    由于 npm/yarn/pnpm 的依赖提升机制,传递依赖可能被提升到 node_modules 根目录,导致代码"意外可用":

    // 项目 A 的代码
    import _ from 'lodash';  // 能运行!但 A 的 package.json 没声明 lodash
    

    三大风险

    风险说明
    幽灵依赖A 没声明但"可用",B 不再依赖 lodash 时 A 就会崩溃
    版本不可控A 使用的版本完全取决于 B,B 升级可能导致 A 出问题
    环境不一致本地可用,CI/生产可能因不同安装策略而找不到

    六、文件级循环检测:import/no-cycle

    即使项目间没有循环依赖,项目内部的文件之间仍可能形成循环依赖。并且要求文件级检测的粒度更细,用于检测同一个项目内部文件之间的循环依赖

    核心原理:DFS 图遍历

    检测思路

    问题:如何判断文件 A 导入文件 B 后,是否存在从 B 回到 A 的路径?

    答案:使用**深度优先搜索(DFS)**遍历模块依赖图,检查是否能够回到起始模块。

    // dep-a.ts
    import { b } from './dep-b';  // A → B
    
    // dep-b.ts
    import { c } from './dep-c';  // B → C
    
    // dep-c.ts
    import { a } from './dep-a';  // C → A ❌ 形成循环 A → B → C → A
    

    算法流程

    以 oxc 的实现为例,核心检测逻辑如下:

    在 DFS 遍历过程中,如果当前文件的路径等于起始文件路径,则发现循环依赖。

    项目配置

    例如

    {
      "rules": {
        "import/no-cycle": [
          "error",
          {
            "maxDepth": 10,
            "ignoreTypes": false,
            "ignoreExternal": false,
            "allowUnsafeDynamicCyclicDependency": false
          }
        ]
      }
    }
    

    配置选项说明

    选项类型默认值推荐值说明
    maxDepthnumberInfinity10限制依赖遍历的最大深度,避免性能问题。大型项目建议 10-20
    ignoreTypesbooleantruetrue忽略 TypeScript/Flow 的类型导入。建议保持 true,类型导入不形成运行时循环
    ignoreExternalbooleanfalsetrue是否忽略外部模块(node_modules)的循环。建议 true,外部依赖由包管理器处理
    allowUnsafeDynamicCyclicDependencybooleanfalsefalse如果依赖链中存在动态导入,是否允许循环。建议 false,动态导入仍可能形成运行时问题

    配置建议

    1. 大型项目:设置 maxDepth: 10-15,避免深层遍历
    2. 小型项目:可以使用默认值或 maxDepth: 5
    3. Monorepo:建议 ignoreExternal: true,只检测工作区内部文件
    4. TypeScript 项目:必须设置 ignoreTypes: true,避免类型导入误报

    总结

    如果你也在为项目中混乱依赖困扰,不妨开始建立依赖约束方式:

    1. 项目级检测:自定义 ESLint/Oxlint 插件
    2. 文件级检测:配置 import/no-cycle 规则,检测项目内文件的循环依赖

    两者配合,在CI/CD中持续监控

    Jimhug

    基础不牢,地动山摇

    Share with the post url and description