NoDiff 是一个专门跑在 monorepo 里的 framework我第一次接触它的时候想法很简单仓库里的包很多、提交很频繁每次有一点改动就触发全量构建和全量测试CI 成本只会越滚越大。NoDiff 给我的核心印象是它不打算做成一个和项目无关的独立平台而是直接活在仓库结构里围绕代码差异做影响分析再告诉 CI 哪些包需要重新构建、哪些测试需要重新跑。这篇文章适合 monorepo 维护者、CI 负责人以及从多仓库迁到单仓库后觉得发布和测试链路越来越重的团队。后面不按功能列表讲而是按实际评估和落地顺序拆一遍先解决什么问题、需要哪些数据、怎么最小跑通、批量怎么处理以及哪些地方容易踩坑。1. 先说清楚 NoDiff 这种框架到底想解决什么问题1.1 monorepo 的痛点不是仓库大而是“怎么知道一次提交影响了哪些包”很多人以为 monorepo 最大的问题是仓库体积大、pull 慢、磁盘占用高。实际上仓库大只是表象。真正让团队难受的是一次提交只有一百行代码但对应的包被构建、被测试、被发布时要么整仓全量跑一遍要么就是漏掉那些应该被重新验证的包。这两个问题方向相反但来自同一个根源缺少对“变更影响范围”的准确判断。还有些人是第一次听说 monorepo 是啥以为只是把所有项目塞进一个 Git 仓库。这个理解方向对但不够完整。monorepo 真正讲究的是用一套工具链去管理多个包、组件或服务的依赖关系让它们共享版本策略、构建配置和发布流程。如果只是把代码放一起依赖关系还是靠人工维护那么仓库越大日常开发和 CI 就越容易出问题。NoDiff 这类框架存在的意义就是把这个“判断影响范围”的过程工具化。它不负责构建代码也不直接跑测试而是生成一份清单这次提交到底动了谁、谁依赖了这个“谁”、所以谁也必须重新构建或重新测试。这份清单可以交给现有的构建脚本、CI 流水线或者任务队列。1.2 从 NoDiff 这个名字看它的核心思路NoDiff 这个命名很有意思。字面意思是“没有差异”但落到工程里它更像在表达通过合理的变更分析让大多数提交不再触发那些无关的构建任务。也就是说当一次提交只影响一个底层工具库时上层应用不应该被拉起来重新构建也不需要全套重新测试。这个“不需要”就是 NoDiff 的价值。我在评估这类框架时会先看它有没有把两个环节拆开。diff 检测拿到基线分支、当前分支、提交哈希算出文件级变更列表。影响分析把文件变更映射到包再把包放到依赖图里找到需要重建的相关包。如果这两个环节没有拆开后面做缓存、做并发调度、做失败跳过都会很麻烦。好的实现一定先把这两个层次分开。这样就算不用它内置的任务调度器你也能拿到关键结果自己接别的工具。1.3 它和传统 diff 工具、CI 全量构建有什么不一样传统做法一般有两种靠 git diff 直接列文件但文件列表和包依赖之间没有自动映射改一个公共组件时默认把所有下游包都纳入构建等于变相全量或者靠人在 MR 描述里手动勾选“影响范围”依赖经验容易漏。NoDiff 这类框架把映射关系自动化之后主要带来两个变化不用在每次提交时手动判断影响范围减少人工决策。任务范围更精确只跑真正受影响的包或者只构建被变更的包及其依赖链。不过要注意精确不代表绝对正确。任何基于 diff 的影响分析都可能漏判比如某个包通过动态引用、反射、配置文件隐式依赖了另一个包。所以框架通常需要额外配置来声明这些隐藏依赖。这个边界后面专门讲。2. 在 monorepo 里做变更影响分析需要先有哪几样数据2.1 文件归属每个文件属于哪个包最简单的数据模型是包根目录。如果 monorepo 使用 packages/a、packages/b 这样的结构一个文件属于哪个包通常可以直接通过文件路径判断。难点在于有些文件位于仓库根目录比如公共的构建配置、依赖锁定文件、部署脚本这些文件一旦变更理论上会影响所有包。所以文件归属表必须能处理两种情况普通包内文件以及根级公共文件。处理根级公共文件的方式通常有两种把这些文件单独标记为“对所有包生效”或者配置一个前缀规则告诉框架哪些目录涉及全仓库。没有这个规则影响范围就容易算小。我一般会先拿一个真实提交做测试重点看根目录文件变更时受影响包列表是不是覆盖了所有应该覆盖的包。如果根级配置变了结果却只有一两个包被选中那说明文件归属配置还不完整。2.2 包依赖图包与包之间谁依赖谁光知道文件属于哪个包还不够。假设 package A 被 package B 引用A 的文件变更了B 必须重新测试C 如果依赖 B也可能受影响。这里需要一张有向依赖图方向通常从被依赖方指向依赖方。NoDiff 在 monorepo 里能起作用很大程度上依赖这张图的质量。依赖图可以从两个来源生成包管理器的 workspace 配置pnpm workspace、npm workspaces、yarn workspaces 中声明的依赖关系。源码里的 import、require、动态引用如果团队用了很多别名路径或者通过字符串拼接动态引用包光靠包管理器配置不一定准确。我建议优先支持这两种来源的合并并且在配置里留一个手工补充依赖的入口。因为实际项目里总有例外某个包通过 shell 脚本调用另一个包的 CLI这种关系只有靠手工声明才能被纳入影响分析。2.3 提交历史从哪个基线分支开始做 diffdiff 的基线很关键。常见做法是拿目标分支和源分支的 merge-base 做 diff而不是直接拿当前 HEAD 和远端主干的最新提交做 diff。直接 diff 会把主干上其他人的提交也算进来导致结果范围偏大。理想情况下应该先算 merge-base再取 merge-base 到当前提交之间的文件变更。这里还需要处理几个场景第一次接入 NoDiff 时如何确定一个合理基线是全量跑一次还是从最近一次发布标签开始。分支合入主干之后后续提交是继续增量分析还是每次合入都做一次全量验证。上一次构建失败了下一次提交需要从上次失败的包继续而不是只分析最新提交里的文件。这些点如果框架默认不支持就需要在 CI 流程里自己补。很多工具在这里做得不完备前期用起来很顺一到分支并发就出问题。3. 一个最小可复现的 NoDiff 使用思路3.1 单次提交的差异检测流程如果你在一个 pnpm workspace 的 monorepo 里使用这类框架最直接的第一步不是改全局配置而是先手动验证它能不能正确算出一次提交的受影响包列表。典型流程是指定一个基线比如 origin/main。指定一个当前提交比如当前分支的 HEAD。让框架输出文件级变更列表。让框架把文件列表映射到包。让框架基于依赖图输出受影响包清单。验证标准很朴素只改 packages/a/src/index.ts 时清单应该包含包 a以及依赖 a 的包结果里不应该出现完全没有依赖关系、也没有被改动的包输出要有稳定的排序和格式方便后续脚本消费。这一小步能跑通后面接 CI 才有意义。如果第一步就有问题先不要着急接批量任务和 CI因为你后续调试成本会非常高。3.2 生成受影响包列表后让构建和测试只跑这些包拿到受影响包列表之后下一步是把列表交给构建和测试系统。这里有一个很容易忽略的设计列表应该统一输出到一个地方比如 JSON 文件或环境变量而不是在框架内部直接去执行构建命令。原因很简单你的构建命令可能是 pnpm run build又可能是 npm test也可能是自定义脚本框架不应该绑定具体执行器它只需要告诉你怎么判断范围。我自己在项目里的做法是先让框架输出包名列表到一个临时文件然后让下一阶段的任务读取这个文件。这样即使框架升级CI 里的主流程也基本不用改。一份结果文件的示例格式可以是这样{ baseline: origin/main, commit: abc1234, changedFiles: 12, affectedPackages: [packages-a, packages-b] }这里需要说明JSON 字段名只是我为了讲清楚数据流而写的示例不一定等于 NoDiff 的实际输出结构。接入的时候先跑一次真实场景看它实际输出什么字段再按实际结构处理。拿到列表之后CI 脚本可以这样消费结果这也是一个通用流程示例# 从结果文件读取受影响包列表再逐个执行构建 cat affected.json | jq -r .affectedPackages[] | while read pkg; do echo handle $pkg done不要把这个脚本当成 NoDiff 官方命令它只是让你明白“框架输出结果上层任务消费结果”这个协作方式。3.3 增量任务的输入输出设计和验证如果只跑一次单提交前面的流程已经够用了。但 monorepo 里往往是频繁提交构建任务很容易排队。要想把受影响包列表变成可靠的任务系统需要定义清楚输入和输出。输入至少包括基线分支和当前提交。受影响包列表。任务类型build、test、lint、publish。并发上限。超时时间。输出至少包括每个包的任务状态成功、失败、跳过、排队。日志路径。耗时。产物缓存标识。验证时可以从一条链开始先跑一个包的任务再跑两个有依赖关系的包任务最后跑一个依赖失败时的重试场景。不要一开始就模拟几十个包出了问题不好定位。4. 批量场景下要处理的队列、跳过和失败重试4.1 别急着开全并发先看任务之间的依赖顺序很多人在批量场景里的第一反应就是把并发数调到最大。在 monorepo 里这样做很可能在短时间内把 CI 机器的 CPU 和内存打满尤其是多个包同时构建时容易相互争抢资源整体速度反而更慢甚至出现 OOM。正确的顺序是先根据依赖图确定任务拓扑。如果包 A 是包 B 的依赖那么 A 的任务必须先跑完。如果两个包没有依赖关系可以并行。NoDiff 这类框架里依赖图不只是用来算影响范围也是用来做任务调度排序的依据。我一般建议把并发数从 1 开始逐步加到 2、4、8。每次加并发之后看两个指标单任务耗时有没有变长整体吞吐有没有提升。如果单任务耗时明显被拉长说明资源已经紧张并发上限不用再继续加。4.2 失败重试与任务跳过哪些错误值得重试批量任务里失败重试必须做但不能什么错误都重试。比较稳妥的分类是这样网络波动、依赖下载超时、远程缓存拉取失败值得重试通常重试两到三次有效。编译报错、测试断言失败、代码语法错误重试没有意义应该直接标记失败并通知人工。磁盘空间不足、权限问题重试也没有用需要先清理环境或修复权限。如果框架支持按错误类型配置重试策略最好利用起来。如果不支持也要在 CI 外层对任务结果做一层判断而不是盲目把整个任务重新跑一遍。跳过逻辑也很重要。当某个包因为受影响才进入任务列表但它的实际产物没有变化此时可以直接跳过构建。但测试任务通常不建议轻易跳过因为即使代码没有变依赖行为变了也会影响结果。这个区分需要在配置里用显式字段控制。4.3 用一个批次矩阵管理多包任务批量任务多了以后只看单个包的成功失败不够。我建议维护一个简单的任务矩阵行是包列是任务步骤。这样每次跑完至少能回答哪些包没进入本次任务。哪些包完成了构建但还没测试。哪些包失败了失败原因是构建还是测试。哪些包被跳过为什么跳过。统计结果可以直接输出成 Markdown 表格或 JSON 文件放到 CI 产物里。好处是排查问题时有据可查而不是靠记忆。任务矩阵不一定要用重平台实现一个 JSON 状态文件或者一组日志路径列表都能做到关键是让每次运行都可追溯。5. 实际落地时的常见边界和坑5.1 低配环境能跑不代表全仓库扫描也快我自己测试小仓库时很顺畅但放到一个几百个包的 monorepo 里文件扫描、依赖图构建、diff 计算都可能变慢。这个不一定是框架本身的问题而是数据量变了。遇到这种情况先做三件事确认包管理器的 workspace 缓存是否正常避免每次重新解析所有包。确认 diff 是否只扫描变更范围内的文件而不是每次都遍历完整仓库历史。确认影响分析结果有没有被上层脚本重复计算最好计算一次并缓存起来。低配环境能跑说明逻辑没问题但要做全仓库级、频繁提交的 CI 任务还是要单独评估耗时和资源占用。5.2 如果 diff 检测逻辑卡住先排查命令、路径和权限这类框架最容易出问题的地方往往不是算法而是前置条件。比如 Git 命令是否有权限访问仓库、仓库是否处于 detached HEAD 状态、merge-base 计算失败、文件路径包含空格或特殊字符、包名和目录名不一致。排查顺序我一般是先看最原始的命令输出比如 git status、git diff --name-only确认能拿到文件列表再确认包映射是否正常。如果文件列表都拿不到后面所有分析都是白搭。注意遇到报错先别急着改框架参数。先看日志里的原始命令和返回值很多时候是路径、权限或 Git 状态的问题。5.3 不要把所有逻辑堆在框架里留好逃生舱NoDiff 这类框架在 monorepo 里很诱人的一点是可以往里面加很多自动化解逻辑。但我的经验是不要让框架变成黑盒尤其是它自己维护的那份依赖关系数据。如果团队规模不大、项目还不稳定最好保留一个手动触发全量构建的入口。做法很简单增加一个开关比如 FULL_BUILDtrue 时忽略影响分析的结果强制全量跑。这样即使框架的依赖分析因为某次特殊改动失误了团队也能有一条后路。否则一旦自动分析出问题整个 CI 都会被卡住修复成本非常高。5.4 和 CI 集成时最容易忽略的是任务超时和日志接入 CI 之后最常见的情况是框架本身运行正常但 CI 流水线超时。原因通常不是框架慢而是任务数量变大以后单个 Job 的默认超时不够用或者日志输出太多导致 CI 卡在日志处理上。解决办法给每个任务单独设置超时时间而不是让整个流水线共享一个超时上限。日志保存到文件CI 控制台只显示简要状态避免海量输出阻塞界面。对有长任务的包单独设置更长超时比如 30 分钟以上。如果使用了自动化框架最好区分任务执行时间和日志采集时间不要混在一起统计。这些经验适用于大部分 monorepo 的 CI 落地不限于 NoDiff。6. 判断 NoDiff 值不值得用的几个标准6.1 速度和准确率怎么衡量判断这类框架是否值得持续使用我建议盯两个指标。第一个是准确率。在人为构造的 N 个提交样例里框架算出的受影响包和人工判断结果一致的比例。准确率不是越高越好但漏判比多判严重得多。漏判意味着该跑的测试没跑多判只是浪费 CI 时间。第二个是加速比。接框架之前平均一次提交触发多少包任务接之后平均一次提交触发多少包任务。如果触发量并没有明显下降说明仓库里存在大量直接依赖根级公共文件的情况增量收益有限。如果准确率和加速比都一般那说明当前仓库的结构可能不适合增量分析或者公共文件太多、包的分层不够干净。这时更适合先重构代码结构而不是换下一个框架。6.2 和 git submodules、GitHub Actions、Jenkins 的配合很多团队选择 monorepo 之前可能已经用过 git submodules。git submodules 的好处是仓库边界清晰坏处是把依赖关系交给开发者手动维护版本不同步时很容易出现“本地构建通过CI 构建失败”。monorepo 和 git submodules 不是同一个维度的东西前者是仓库组织策略后者是仓库拆分手段。如果团队希望减少依赖漂移问题monorepo 配合 NoDiff 这类增量分析工具会更合适。在 CI 平台选择上NoDiff 的核心能力通常是先算出变更范围再输出到下一步。所以主流 CI 基本都能接GitHub Actions、Jenkins、GitLab CI 都可以。关键是找到适合你的 CI 系统取出 diff 结果的方式。GitHub Actions 里可以用工作流判断输出文件或环境变量Jenkins 里可以让下一步 job 读取 JSON 文件。没必要为了某个框架替换现有 CI。6.3 什么情况下不要用增量直接全量增量分析不是所有场景都合适。如果你们的 monorepo 只有三五个包构建一次只要一分钟那么强行上 NoDiff 的意义不大引入复杂度反而大于收益。简单场景直接全量构建稳定可靠没有边界问题。如果出现这些信号也可以先不做增量所有包都依赖同一个根级公共配置几乎每次改动都会影响所有包。构建结果没有缓存只构建一个包和全量构建耗时差不多。依赖图不完整或不准确无法把漏判风险控制在可接受范围内。团队对 monorepo 的包边界还没有共识经常出现循环依赖。在这些情况下优先解决仓库结构和依赖关系比引入新框架更重要。6.4 如果只是团队学习或小型项目怎么轻量试如果只是想体验 NoDiff 这类框架的思路不一定要立刻部署到生产 CI。可以这样轻量尝试本地跑一遍单提交的受影响包列表观察结果是否和直觉一致。写一个最小脚本把受影响包列表转换成构建命令。手动对比一下全量构建耗时和增量构建耗时。如果没有明显差距先不引入如果有明显差距再把框架接入 CI但保留全量开关。这样既能验证价值也不会因为盲目上增量而把 CI 搞复杂。最后说一个我自己的判断NoDiff 这类跑在 monorepo 里的框架真正解决的不是“如何把代码放一起”而是“代码放一起之后如何不让仓库规模成为构建和测试的负担”。它适合在包数量多到全量 CI 成本不可忽略的时候介入不适合从一开始就当成必选项。评估时多关注它能不能准确输出受影响包列表能不能让构建和测试按需执行以及接入 CI 之后超时、失败重试、日志这些工程细节有没有留出空间。这些比功能列表更关键。