关于我
Monorepo 工程范式第一部分 · Rush 实战
Rush 概述

Rush 概述

选定 Rush 之后,先别急着一条条学命令。这一章按一个数百包的仓真实的生命周期——从搭起来、跑起来,到发出去、再接进 AI——把 Rush 的能力串成一条线,建立一张全景图。贯穿全章的一个判断是:仓库随规模增长天然走向混乱,Rush 的每一项能力都是冲着其中某一种混乱去的;看一项能力,先问它挡住的是哪一种熵。

上一章我们把选型这件事聊清楚了,结论是 Rush。而 Rush 是个不小的工具:几十条命令、十几个配置文件,能力多、入口也多。直接一条条学,很容易只见树木不见森林——所以动手之前,先花一章建立一张全景图。

这张图不逐条讲命令,而是回答一个问题:Rush 这套工具到底管了哪些事。我会按一个数百包的仓真实的生命周期——从搭起来、跑起来,到发出去、再接进 AI——把它的能力串成一条线。读完这一章,你应该能说清 Rush 的能力边界、每一块从哪个命令入手,以及更重要的:每一块各自在对抗大仓里的哪一种混乱。后续每一章都是这张全景里某一块的展开,读到那里时你能直接对号入座。

有一条线索会贯穿全章。一个仓库随着项目变多,天然会走向混乱——依赖版本各行其是、构建越来越慢、发布顺序没人记得清。Rush 的每一项能力,几乎都是冲着其中某一种混乱去的。所以下面每讲一块,我都会点出它挡住的是哪一种熵,而不是停在"它恰好有这么个功能"。

1. 安装与初始化

与其一上来读文档,不如先手动跑一遍,对它有个直观的感受。

为了方便动手,本章会用一个开源样例仓库贯穿讲解——coze-dev/rush-arch,它是一个用 Rush 管理 MCP server 的 monorepo,结构完整、体量适中,适合边读边试。后面各节涉及的命令,你都可以在这个仓里直接跑。

Rush 是个全局命令行工具,先装上:

npm install -g @microsoft/rush

如果你想从零体会一个 Rush 仓库是怎么长出来的,可以在一个空目录里执行 rush init,它会把一整套配置骨架铺出来:

rush init

跑完之后,目录里多出这样一批文件:

my-repo/
├── rush.json                          # 主配置文件:项目清单 + 工具版本
└── common/
    └── config/
        └── rush/
            ├── .npmrc                  # 包管理器的 registry 配置
            ├── build-cache.json        # 构建缓存
            ├── command-line.json       # 自定义命令
            ├── common-versions.json    # 全仓统一的依赖版本
            ├── pnpm-config.json        # pnpm 行为配置
            └── version-policies.json   # 发布与版本策略

先不用逐个读懂它们。这里只需要抓住两点:仓库根的 rush.json 是主配置,一切从它开始;而 common/config/rush/ 下那一批文件,分别对应后面各节要讲的依赖治理、构建缓存、发布策略等能力——这一章逐节讲下去,就是在给它们一个个安上语境。样例仓 rush-arch 的结构与此一致,clone 下来就能看到同一套骨架。

配置就位后,把依赖装上。安装依赖用 rush update——在样例仓里 clone 完直接跑它即可:

git clone git@github.com:coze-dev/rush-arch.git
cd rush-arch
rush update

它会读取每个项目的 package.json,一次性把整个仓库的依赖都装好。装完就能跑构建了:

rush build

到这里,一个 Rush 仓库就从零跑起来了:装工具、初始化、装依赖、跑构建。下面几节,就沿着这条主线把每一步背后的能力拆开讲。

2. 项目清单

rush.json 最核心的一块,是一份项目清单。它里面有一个 projects 数组,仓库里的每个项目都要在这里显式登记一行——写清它的包名(packageName)和所在目录(projectFolder):

{
  "projects": [
    { "packageName": "@app/blog", "projectFolder": "apps/blog" },
    { "packageName": "@infra/eslint-config", "projectFolder": "infra/eslint-config" }
  ]
}

仓里到底有哪些模块、各自落在哪个目录,答案就集中在这一处。有了这份清单,Rush 就知道了依赖图上有哪些节点;再顺着各项目 package.json 里声明的相互依赖,把节点之间的连起来,一张完整的依赖图就成型了。Rush 后续做的所有事——排构建顺序、并行调度、圈定构建子集——都建立在这张图上,而图的地基,就是这份节点清单。

后面几节会讲到的能力,几乎都能顺着这条线接出来——清单先长出依赖图,依赖图再撑起选择器、并行调度和增量判定:

rush.json项目清单 依赖图 选择器圈定子集 并行调度算构建顺序 增量与缓存判定谁要重建

顺带一提,rush.json 里除了项目清单,还锁定了 rushVersionpnpmVersion 和支持的 Node 版本范围——把"这个仓该用哪个版本的工具"也一并钉进同一份文件。这一点关系到本地与 CI 的环境一致,我们留到后面环境一致性一章再展开。

展开见第 5 章:rush.json 的完整字段、单一版本约束,以及这份清单还能承载哪些治理规则。

3. 依赖安装

上一节那条 rush update 一带而过,这里把它背后的事讲清楚。一个数百包的仓,依赖安装要解决两个问题:把上千个依赖装对,而且保证每次装出来都一样。

装,只需要一条命令。改完 package.json 后运行 rush update,Rush 会把所有项目的依赖一次性装进 common/temp 下的一个公共目录,再用 symlink 为每个项目还原出一份准确的、只含它自己那部分依赖的 node_modules:

# 修改了任意 package.json 之后
rush update

这里的关键是"准确"。装完之后,依赖的落位大致是这样:

rush-arch/
├── (根目录没有 node_modules)         # ← 这是 Rush 和多数方案最不同的一点
├── common/
│   └── temp/
│       └── node_modules/             # 全仓依赖真正安装的地方(扁平、无 symlink)
└── packages/
    ├── pkg-a/
    │   └── node_modules/             # 由 symlink 还原,只含 pkg-a 声明过的依赖
    └── pkg-b/
        └── node_modules/             # 同上,只含 pkg-b 声明过的依赖

需要注意的是,多数方案会在仓库根放一个 node_modules,项目跑起来时,寻址会一路向上退到根目录——结果是任何项目都能 require() 到根上、乃至隔壁项目装的包,哪怕自己的 package.json 里根本没声明它。这就是幽灵依赖:用着一个没写进依赖表的包,哪天它被移除或换版本,毫无征兆地崩掉。

Rush 走的是另一条路:仓库根不存在 node_modules,每个项目的 node_modules 由 symlink 单独还原,里面只有它自己声明过的那部分依赖。寻址向上退无处可退,谁没声明就是用不到——依赖寻址由此变得严格,幽灵依赖从结构上被堵死。而项目之间的相互引用,Rush 会自动 symlink 打通:改动一个底层包,上层项目立刻能看到效果,不必发布,也无需手动维护 npm link

幽灵依赖与严格隔离对比

至于"每次都一样",靠的是 lockfile(pnpm 下是 pnpm-lock.yaml)。执行 rush update 时,Rush 会把这次解析出的整套安装计划——每个包解析到哪个确切版本、依赖关系如何——完整冻结进这份文件。简化后大致长这样:

# common/config/rush/pnpm-lock.yaml(简化示意)
lockfileVersion: '9.0'

importers:                    # 每个项目声明了哪些依赖
  ../../packages/pkg-a:
    dependencies:
      lodash:
        specifier: ^4.17.0    # package.json 里写的范围
        version: 4.17.21      # 实际锁定到的确切版本

packages:                     # 每个确切版本的完整信息
  lodash@4.17.21:
    resolution: { integrity: sha512-... }

specifier 是你在 package.json 里写的版本范围,version 是这次实际解析锁定的确切版本——只要 lockfile 不变,任何人、任何机器装出来的都是同一套。在 CI 上,你用的是另一条命令:

# CI 上:只按 lockfile 安装,绝不修改它
rush install

rush install 只安装、不改动 lockfile;一旦发现 lockfilepackage.json 对不上(通常是有人改了依赖却忘了提交更新后的 lockfile),它会直接让 CI 失败。这道检查能确保 CI 装出的依赖与提交的 lockfile 完全一致,从而排除"本地能跑、CI 却装出另一套依赖"这类最难查的漂移。

几条命令的分工记住即可:改了依赖用 rush update,想把所有依赖重新解析到最新兼容版本用 rush update --full,CI 上一律 rush installRushpnpm、npm、yarn 三种包管理器都支持,本课程统一以 pnpm 为例。

展开见第 4 章(从零搭仓并引导进 CI)与第 9 章(pnpm 的深水区:幽灵依赖doppelganger 等)。

4. 命令与选择器

依赖装好了,接下来是在几百个项目上跑构建、测试这类活儿。数量一大,"全跑一遍"就不再是个好选项,于是有了两件事:让该跑的并行地跑,以及只跑该跑的那部分。

先说并行。Rush 能从依赖图里算出正确的构建顺序,把彼此不依赖的项目分派到不同进程里同时构建,再把各进程的日志按可读的顺序汇总起来。构建顺序和并行调度都交给了 Rush,你不必手动编排——这正是它构建快的来源。日常最常用的两条命令是:

rush rebuild   # 全量、干净地构建每个项目
rush build     # 增量:只构建变过的项目(下一节细说)

想在单个项目目录里跑它自己 package.json 里的脚本,用 rushx,相当于更顺手的 npm run:

rushx test

再说"只跑该跑的"。Rush 给所有批量命令配了一套统一的项目选择器,让你圈出一个子集来跑。最常用的几个:

rush build --to @app/blog          # @app/blog 加上它所有的上游依赖
rush build --from @infra/eslint-config   # 这个包、依赖它的所有下游,以及这些项目各自需要的上游
rush build --only @app/blog        # 只有它自己,依赖一概不管

选择器可以叠加,多个条件取并集。被选中的目标除了写项目名,还能用一些更灵活的写法:. 表示当前目录所在的项目,git:<ref> 表示自某个提交或分支以来有改动的项目,此外还有 tag:subspace: 等。这里先建立印象,完整用法留到专门的一章。

选择器加依赖图,挡住的是这样一种熵:改一行代码就得把整个仓库重跑一遍。有了它,工作量跟着你改动的范围走,而不是跟着仓库的规模走——这正是大仓还能跑得动的前提之一。

展开见第 5 章:选择器的完整语法,以及基于它的 affected 构建。

5. 增量与缓存

并行和按需已经省下不少时间,但仓库大到一定程度还不够,还需要另一件事:不重复做已经做过的事

那么,如何判定一个任务是否需要重新做呢?本质上,一个任务的产物由它的全部输入唯一决定,只要输入没变,产物就不会变,这一次就可以跳过。所谓"输入",无非几类:

  1. 源文件:任务自身的代码和资源文件;
  2. 依赖:它所依赖的上游项目的产物;
  3. 命令行参数:这次调用带的参数;
  4. 环境变量:构建过程读取的环境变量。

把这几类输入拼起来算出一个指纹,和上一次比一比:全都对得上,就判定为已是最新,直接跳过;任意一项变了,才重新构建。这套判定不绑定任何具体框架,任何增量构建系统的内核都是这一套。

增量解决的是"同一台机器、同一个人"重复构建的问题。跨机器、跨成员的重复,靠的是构建缓存:把每个项目的构建产物压缩成一个包,按输入的哈希存起来,存在本地磁盘,也可以存到云端(如 Azure Blob、S3)。下次谁的输入哈希对得上,直接把产物解出来,连构建都省了。Rush 原生支持这套缓存能力,具体的开启与配置后面会单独展开,这里不赘述。

在缓存之上,Rush 还有两块面向规模的能力:cobuild 让多台机器共享同一份缓存、用锁分工,协作跑完同一条流水线;phased builds 把单个项目的构建再拆成 buildtest 等阶段,进一步榨出并行度。

把这一节的三层能力串起来看,它们对抗的是同一种熵:仓库越大,重复的构建就越多——同一份代码在本地被反复构建、在每个成员的机器上各建一遍、在每次 CI 里从头再来。增量、缓存、cobuild 层层递进,让构建量跟着真正的改动走,而不是跟着仓库规模、人数、CI 次数一起膨胀。

构建复用的三层递进

展开见第 13 章:构建缓存的本地与云端配置、cobuild 与 phased builds。

6. 依赖治理

前面几节都在讲"怎么跑得快",这一节转向另一类问题:仓越大,依赖越容易乱,得有东西管住它。

这里的"乱",举例来说:

  • 同一个库,A 项目锁在 react@18、B 项目还停在 react@17,两个版本在同一个仓里共存——打包体积膨胀,还可能因为运行时存在两份实例而出诡异的 bug;
  • 某人随手 npm install 了一个来路不明、许可证存疑的包,悄悄进了依赖树,成了供应链风险的入口;
  • 项目目录一层套一层地随意铺开,谁也说不清仓库该长成什么结构,新人越来越难上手。

这些事单看都不大,可一旦仓库有几百个项目、几十号人同时在动,不加约束就会累积成实实在在的稳定性风险。问题的本质在于规模:项目和人一多,每个人随手引入的小差异叠加起来,依赖就自然滑向失序——依赖治理要做的,就是在规模把这些差异放大之前,用一道自动检查把它们拦下

最基础的一道闸是单一版本。在 rush.json 里打开 ensureConsistentVersions,Rush 会强制所有项目对同一个库使用一致的版本号——A 项目用 react@18、B 项目用 react@17 这种事,在安装时就会被拦下来,报出各版本被哪些项目引用:

react
  ^18.2.0
   - @app/blog
  ^17.0.2
   - @app/legacy-admin

Found 1 mis-matching dependencies

除了版本,Rush 的治理能力还覆盖别的维度。你可以要求新增一个依赖前先走审批(approvedPackagesPolicy),避免来路不明的包随手就进了仓;也可以约束项目目录的层级深度,不让仓库结构随意膨胀。这些规则的共同点,是把原本要靠人留神才能守住的边界,固化成 Rush 在安装时的一道自动检查——项目再多、人再多,该拦的也不会漏。

不过这些治理手段都有边界。它们本质上是软约束——流程层面的把关,不是技术上绕不过去的硬隔离:拦得住无心之失和图省事,却拦不住一个铁了心要绕开的人;而且管得越严,新增一个依赖的摩擦就越大。治理的价值,在于把正确的默认路径变得最省力,至于严到什么刻度,是每个团队要自己权衡的。

展开见第 5 章(单一版本)、第 7 章(依赖约束与治理)与第 8 章(把约定写成自定义命令与 hook)。

7. 子空间(Subspaces)

上一节的单一版本,是建立在"全仓共用一份 lockfile"这个前提上的。一份 lockfile 本质上是一个巨型的多元方程:它要在所有项目之间协调出一套彼此兼容的版本选择,消解冲突、压掉重复。项目一多,这个方程就越来越庞大——每次依赖变动都要重解整个方程,不但慢,某个遗留项目一个刁钻的版本要求还可能让整个方程解不开,卡住所有人。

子空间(subspaces)是应对这种情况的机制:它允许一个 monorepo多份 pnpm lockfile 来安装。每个项目归属于某一个子空间,每个子空间有自己独立的 pnpm-lock.yamlcommon-versions.json.npmrc。把一个大方程拆成几个小方程,它解决的主要是两件事:超大代码库里,小方程各自更快更好解,超大团队可以分治;一批依赖和主仓对不齐的遗留项目,可以单独隔离成一域,独立管理自己的版本,不再拖累全局。(子空间需要较新的 Rushpnpm 版本,具体门槛到那一章再列。)

子空间把大方程拆成小方程

代价是,方程虽然各自变小了,版本管理的总开销反而变大了:lockfile 从一份变成多份,跨域的一致性得你自己另想办法,维护面跟着变大。所以它有一条适用前提——只有当团队大到"分摊工作比减少总工作量更重要"时才划算,官方也建议子空间越少越好。复杂度并没有消失,只是从"版本对齐"挪到了"多域协调"。

展开见第 10 章:子空间的划分策略、多 lockfile 的维护,以及它带来的新问题。

8. 发布与部署

一个 monorepo 里往往有几十上百个要对外发布的包,难点不在单个包怎么发,而在怎么把它们关联起来一起发:改了一个底层包,顺着依赖链 lib3 → lib2 → lib1 → app,上游每一个包都得跟着发新版,版本号还要彼此对齐。哪些包受了影响、按什么顺序发、各自升到什么版本,仓一大,光靠人去理清这些就极易出错。

依赖链联动发布

Rush 把这件事拆成两个阶段来管:开发时追踪变更,发布时聚合执行。

第一阶段在开发时进行。改动了一个要发布的公共包,开发者需要运行 rush change,它会交互式地问这次改动是 major、minor 还是 patch,并要求写一句变更说明,生成一个变更文件存进 common/changes。这么做的本质,是把"改了什么、该升什么版本"这类信息,在日常提交的当下就顺手记录下来,而不是攒到发布时再回头逐个追溯。

第二阶段是发布。rush publish 默认是 dry-run(只演示不实际发布),确认无误后加 --apply 执行。它会把散落在各个 PR 里的变更文件聚合起来,自动算出每个包该升到什么版本,写进各自的 CHANGELOG.md,再逐个发布。多个包是锁步同版本还是各升各的,由 version-policies.json 里的版本策略决定。

发布之外还有部署。rush deploy 会为某个应用项目算出它加上生产依赖所需的最小文件集,拷到 common/deploy 目录,供你打包上传到服务器。

有一点容易混淆:依赖图不等于发布计划。构建顺序是 Rush 从依赖图里算出来的,而"这次发哪几个包、各自版本怎么升",由变更文件和版本策略决定——前者管"谁先构建",后者管"谁要发布、发成什么版本",是两码事。

展开见第 11 章(环境一致性)与第 12 章(发布与部署的完整流程)。

9. AI 接入

回到第 2 节埋下的那条线。从一份显式的项目清单出发,一路串起了依赖图、选择器、增量判定,这些东西合起来,恰好是一个人第一次接手大仓时最想先搞清楚的:仓里有什么、谁依赖谁、动一处会波及哪里。

AI 需要的是同一份东西。一个编码助手要在几百个项目的仓里干活,同样得先回答"这仓有哪些项目、边界在哪、依赖关系如何"。区别在于:面对一个靠目录约定推导项目的仓,它得先扫一遍目录、按约定推断一轮;而 Rush 把清单和依赖关系都显式摆了出来,这轮推导可以直接跳过——项目边界是现成的结构化事实,既更可控(不用赌它推得对不对),也更省 token(不必把整棵目录树喂给模型再让它归纳)。同一份结构化元信息,对人和对 AI 是同一份价值。

Rush 官方为此提供了 MCP 服务 @rushstack/mcp-server,让 Copilot、Cursor、Claude Code 这类助手能直接查询和操作 monorepo,也能通过插件扩展出团队专属的能力。在此之上,本课后面还会讲一些把 Rush 接进 AI 工作回路的自研实践(比如一个自研的、非 Rush 原生的 rush increment 命令),这里先不展开。

不过要说清楚:结构化元信息只是降低了 AI 理解仓库的门槛,让它少走弯路,并不会让 monorepo "自己管好自己"。前面几节讲的那套依赖图、治理、发布机制,才是让仓库跑得动的地基——地基本身够扎实,AI 才有一份可靠的事实可查,接进来的效果才谈得上好。

展开见第 14 章:Rush MCP 的配置,以及把 Rush 接进 AI 回路的实践。

小结

把这一章倒过来看,Rush 的能力其实排在一条很自然的生命周期线上:

初始化 登记项目 装依赖 跑构建并行·选择·增量·缓存 管住依赖 切子空间 发布部署 接进 AI

顺着一条生命周期线看下来,这一章有三个重点:

  1. rush.json 里那份显式的项目清单是理解 Rush 的原点,后面所有能力都从它长出来。
  2. 这些能力不必去背,它们不是一张平铺的功能清单,而是被"仓库随规模增长而来的混乱"一块一块逼出来的——看一项能力,先问它挡住的是哪一种熵,就抓住了它的本质。
  3. 复杂度不会被消灭、只会被搬运,子空间就是最清楚的例子;后面每遇到一个"解决方案",都可以追问一句:它把复杂度搬去了哪里。

下一章,我们就从零开始,把这套工具在一个真实的仓库里搭起来,并引导进 CI。

On this page