想象你经营着一家连锁餐饮集团,旗下有三家分店:中餐、西餐、甜品。过去,每家分店都有自己的小厨房:各自买菜、各自囤货、各自抄菜谱。结果同一瓶酱油买了三份,同一道菜的配方改了三次,三家店的出餐节奏完全对不上。某天你痛下决心:建一座中央厨房。菜谱统一存放,食材统一采购,每道菜共用同一份材料;再请一位总调度,记录每道菜做到哪一步、哪些菜可以提前备好。这就是代码世界的 Monorepo——一个仓库管理多个项目(包),pnpm 负责“食材”(依赖)的统一存储,Turborepo 负责“出餐节奏”(构建任务)的编排与缓存。这篇文章会用这座中央厨房的日常,带你理解 Monorepo 的依赖管理、任务编排、发布流程,以及它和“各分店独立厨房”(Polyrepo)之间的利弊账。

岗位关联

掌握 Monorepo 管理,你就能为大型前端项目设计高效的依赖与构建体系;在 AI 辅助编码大幅增加代码变更频率的今天,这是前端工程师走向工程化架构师的必修课。

目录
  1. 可视化演示
  2. Monorepo 概念:从分店小厨房到中央厨房
  3. pnpm 依赖管理:统一采购,按需领用
  4. 符号链接存储:一张取菜标签的秘密
  5. Turborepo 编排:总调度手中的工序表
  6. 增量构建缓存:总调度的“成品柜”
  7. 发布流程设计:改过的菜谱才印新菜单
  8. 对比迁移考量:建不建中央厨房,先算回本账
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Monorepo三大核心:pnpm workspace符号链接存储(全局store共享依赖磁盘节省60%+)、Turborepo增量构建DAG(任务拓扑排序+内容寻址缓存命中跳过CI加速5-10倍)、Monorepo vs Polyrepo对比(代码复用率90%+ vs 20%依赖重复6倍),三模式切换理解Monorepo管理全貌。

本节配有交互动效(进入视口后自动加载,约 3KB)。

'+C1+'

Monorepo 概念:从分店小厨房到中央厨房

三家分店各自为政的日子,最累的不是炒菜,而是对账:中餐改了一道配菜,甜品店却毫不知情;同一道宫保鸡丁,三家店有三个版本。Monorepo 的第一步,就是把所有菜谱收进同一本菜谱册——一个 Git 仓库里,同时管理组件库、业务应用、工具函数等多个独立包。菜谱之间可以互相引用,改一处,全局可见。

Monorepo 不是“把代码堆在一起”这么简单,它的关键词是“单仓多包”:每个包仍然有自己的 package.json、自己的版本号、自己的 API 边界,但共享仓库级别的 ESLint 配置、TypeScript 配置、CI 流水线和构建缓存。包之间可以直接引用源码,改完立即生效,不用先发布再安装。

这个模式其实由来已久。SVN 时代大家天然把多项目放一个目录树;Git 流行后“小而美”的 Polyrepo(多仓库)一度成为主流;如今项目规模变大、依赖共享需求变强,Babel、Vue、React 等顶级开源项目重新拥抱 Monorepo,配套工具也从早期的 Lerna 演进到 pnpm workspace + Turborepo 的组合拳。

回到厨房:分店越多,店与店之间的“协调会议”就越多。中央厨房把这些会议变成内部流程,一次对账、全局同步。这就是 Monorepo 最朴素的动机——把平方级的协调成本压缩回线性增长。

C(n)=(n2)=n(n−1)2\displaystyle C(n) = \binom{n}{2} = \frac{n(n-1)}{2}
仓库越多,两两对齐的协调关系按平方级增长——这就是多仓库协作越来越累的数学原因。
  1. 组合数符号 \( \binom{n}{2} \)从 \(n\) 个独立仓库中任意选两个出来协调,有多少种“协调关系”。每家分店都要和其余分店对菜谱。
    (n2)=n(n−1)2\displaystyle \binom{n}{2} = \frac{n(n-1)}{2}
  2. 分子展开 \( n(n-1) \)\(n\) 个仓库中每个都要和另外 \(n-1\) 个协调,但 A 与 B、B 与 A 是同一种关系,所以要除以 2,避免重复计数。
    n(n−1)÷2\displaystyle n(n-1) \div 2
  3. 增长趋势 \( \propto n^2 \)当仓库数量翻倍时,协调关系约变成原来的 4 倍,这就是多仓库协作成本“平方级爆炸”的原因。
    C(n)∝n2\displaystyle C(n) \propto n^2

pnpm 依赖管理:统一采购,按需领用

中央厨房落成的第一件事是重建采购体系。以前每家分店各买各的酱油,现在厨房统一采购,食材进“大仓库”(pnpm store),谁用谁领。这就是 pnpm workspace 的起点:在仓库根目录声明 packages/*,pnpm 自动识别所有内部包,并把它们的依赖统一收纳管理。

pnpm 解决的是依赖管理中的“重复”痛点。npm 的 node_modules 是扁平的,多个项目装同一个库会产生多份物理副本;pnpm 则把每个依赖的真实文件存进全局唯一的 store,项目里只保留一层薄薄的链接。内部包之间通过 workspace: 协议互相关联,不需要先发布到 npm 就能直接联调。

更重要的是依赖解析策略。pnpm 采用内容寻址存储(CAS):文件内容计算哈希作为地址,内容相同就复用同一副本,安装速度快、磁盘占用低。相比之下,npm 的 hoisting(依赖提升)会把依赖全平铺到根 node_modules,导致“幽灵依赖”——你从没声明过它,却能 import 它,就像后厨多了一道你没点却端上来的菜。

就像酱油统一采购、按菜领用,被 3 道菜共用的食材不再买 3 份。pnpm 把“每包一份”变成“全局一份”,省下的不仅是磁盘,还有安装时间和 CI 排队成本。

ΔV=∑d∈D(cd−1)×Vd\displaystyle \Delta V = \sum_{d \in \mathcal{D}} (c_d - 1) \times V_d
同一份依赖被多少个包共享,就省下多少份重复体积;共享得越多,节省越明显。
  1. 依赖全集合 \( \mathcal{D} \)整个仓库所有包用到的第三方依赖合并去重后的集合,是所有“需要采购的食材”的总账。
    D=⋃p∈PDp\displaystyle \mathcal{D} = \bigcup_{p \in \mathcal{P}} D_p
  2. 引用计数 \( c_d \)某种依赖被多少个包同时使用。被 3 个包共用,就只需存 1 份而不是 3 份。
    cd=∑p∈P1[d∈Dp]\displaystyle c_d = \sum_{p \in \mathcal{P}} \mathbf{1}[d \in D_p]
  3. 求和得总节省每种依赖省下的体积是(引用数 − 1)乘单份体积,把全部依赖的节省量相加,就是 pnpm 相比 npm 省下的磁盘空间。
    ΔV=∑d∈D(cd−1)×Vd\displaystyle \Delta V = \sum_{d \in \mathcal{D}} (c_d - 1) \times V_d

符号链接存储:一张取菜标签的秘密

食材收进大仓库后,厨师怎么取用?答案不是把食材搬到自己灶台——那样又回到了重复存储——而是拿一张“取菜标签”:标签上写着食材在仓库的什么位置。这张标签就是符号链接(symlink),一个指向真实路径的指针。厨师凭标签取菜,食材本身仍躺在仓库里。

pnpm 的 node_modules 结构和 npm 完全不同:真实依赖存放在 .pnpm/pkg@version/node_modules/pkg 这样层级固定的目录里;每个包的 node_modules 下只放符号链接,指向 store 中的真实文件;包与包之间的内部依赖,也通过软链接互相指向。整个依赖图像一张由标签组成的蜘蛛网。

这里要分清两个概念:硬链接是文件系统层面的“同一份数据两个名字”,软链接是“一个路径指向另一个路径”。pnpm 对 store 内文件用硬链接/reflink 来省空间,对依赖目录用软链接来保证隔离。正因为只有显式声明的依赖能被链接到,pnpm 天然封死了幽灵依赖——你没点过的菜,后厨不会递到你手上。

每一道菜只需要一张取菜标签而不是一份食材。标签再多,store 里同一份食材依然只有一份。这个“一个真身、多张标签”的设计,正是 pnpm 又快又省的核心秘密。

L=∑p∈Pkp=∣P∣×kˉ\displaystyle L = \sum_{p \in \mathcal{P}} k_p = |\mathcal{P}| \times \bar{k}
链接数量随包数量和平均依赖数线性增长,但每份真实文件在 store 里永远只有一个副本。
  1. 包集合 \( \mathcal{P} \)仓库中所有包(package)组成的集合,每个包相当于中央厨房里的一道菜。
    P={p1,p2,…,pn}\displaystyle \mathcal{P} = \{ p_1, p_2, \dots, p_n \}
  2. 单包依赖数 \( k_p \)每个包实际需要访问的依赖数量,也就是它要贴的“取菜标签”张数。
    kp=∣Dp∣\displaystyle k_p = |D_p|
  3. 平均依赖数 \( \bar{k} \)标签总量等于包数量乘以平均依赖数。注意,标签再多也只是一张张纸条,store 里的真实文件依然只有一份。
    L=∣P∣×1n∑p∈Pkp=∣P∣×kˉ\displaystyle L = |\mathcal{P}| \times \frac{1}{n} \sum_{p \in \mathcal{P}} k_p = |\mathcal{P}| \times \bar{k}

Turborepo 编排:总调度手中的工序表

食材管理好了,接下来是出餐节奏。中央厨房请来一位总调度(Turborepo),它手里有一张工序表:先备菜(lint)、再炒菜(build)、最后摆盘(test),而且它清楚地知道哪些菜可以同时开火、哪些必须等上一道工序完成。

Turborepo 的核心是任务管道(pipeline)。在 turbo.json 里,每个任务声明 dependsOn(依赖哪些前置任务)和 outputs(产物输出到哪里)。Turborepo 读取所有包的配置,把“每个包的每个任务”组织成一张有向无环图(DAG),自动并行执行没有依赖关系的任务,实时汇报每道菜做到哪一步。

以前 Monorepo 不配 Turborepo,只能用 shell 脚本 for 循环串行构建,包一多就出现“构建风暴”:几十个任务排队跑,CI 动不动 40 分钟。Turborepo 的并行调度让互不依赖的任务同时跑,还能按任务分组输出日志,谁慢一眼可见,配合进程复用,构建体感时间骤降。

总调度不会让所有厨师排成一队只用一口锅,而是把不相关的菜同时开火。总出餐时间由最慢的那条链决定——这就是 DAG 并行调度的精髓。

Ttotal=max⁡P∈paths(G)∑j∈PTj\displaystyle T_{\text{total}} = \max_{P \in \text{paths}(\mathcal{G})} \sum_{j \in P} T_j
总出餐时间取决于最长的工序链(关键路径),而不是所有工序耗时的简单相加。
  1. 任务图 \( \mathcal{G} \)Turborepo 把每个包的每个任务看作 DAG 的顶点,依赖关系是边——“test 依赖 build 的产物”就是一条边。
    G=(V,E),E={(u,v)∣v depends on u}\displaystyle \mathcal{G} = (V, E),\quad E = \{(u, v) \mid v \text{ depends on } u\}
  2. 路径集合 paths(\( \mathcal{G} \))从起点任务走到终点任务的所有无环工序链,每条链代表一整套连贯的工序。
    paths(G)={P∣P is a path in G}\displaystyle \text{paths}(\mathcal{G}) = \{ P \mid P \text{ is a path in } \mathcal{G} \}
  3. 取最大值(关键路径)总出餐时间等于所有工序链中最长的那条耗时,这条链叫关键路径,是并行调度下无法再压缩的下限。
    Ttotal=max⁡P∑j∈PTj\displaystyle T_{\text{total}} = \max_{P} \sum_{j \in P} T_j

增量构建缓存:总调度的“成品柜”

更聪明的总调度还随身带一个“成品柜”:昨天炒好的宫保鸡丁,只要菜谱没改、食材没换,今天直接端出成品,根本不用重新开火。这就是增量构建缓存。它把“做过的事”记录下来,避免重复劳动。

Turborepo 会给每个任务计算输入指纹:源码内容、依赖版本、环境变量、命令行参数等全部打散成哈希。任务跑完后,产物和日志打包存进本地缓存目录(.turbo)。下次构建时,指纹一致就直接从缓存恢复产物,任务耗时从分钟级降到毫秒级——就像从成品柜端菜比重新炒菜快得多。

单机缓存是 L1,远程缓存是 L2:把缓存放到共享服务器(如 Vercel Remote Cache),团队的每台开发机和 CI 都能共享命中。于是“代码没变却要重跑”的场景——切分支、重复 CI、同事拉代码——全部被缓存拦截。Git 历史里的每次构建,都可能在未来的某次 CI 中直接“白嫖”。

缓存命中的任务像从成品柜端菜,未命中的任务才需要真刀真枪地炒。真实项目里 CI 提速 70% 以上是常态,这就是总调度“记笔记”的巨大价值。

Teff=h×Trestore+(1−h)×Tbuild\displaystyle T_{\text{eff}} = h \times T_{\text{restore}} + (1 - h) \times T_{\text{build}}
命中率越高,平均构建耗时越接近“从成品柜端菜”的时间,而不是“重新炒菜”的时间。
  1. 命中率 \( h \)输入指纹未变、可以直接端出成品的任务占比,取值范围 0 到 1,它是衡量缓存价值的核心指标。
    h=cache hit taskstotal tasks\displaystyle h = \frac{\text{cache hit tasks}}{\text{total tasks}}
  2. 恢复耗时 \( T_{\text{restore}} \)从本地或远程缓存把产物解压回放的时间,通常比完整构建小一两个数量级。
    Trestore≪Tbuild\displaystyle T_{\text{restore}} \ll T_{\text{build}}
  3. 加权平均按命中率对两种耗时做期望。命中率越高,平均耗时越接近 \(T_{\text{restore}}\),这就是缓存提速的数学本质。
    Teff=h×Trestore+(1−h)×Tbuild\displaystyle T_{\text{eff}} = h \times T_{\text{restore}} + (1 - h) \times T_{\text{build}}

发布流程设计:改过的菜谱才印新菜单

中央厨房上新菜需要一套流程:改菜谱、试菜、更新菜单、通知各分店。Monorepo 的发布也一样,但最大的问题是:这么多菜谱,哪几道需要换版本?如果每次上新都把所有菜谱重印一遍,既浪费又容易出错。

答案是只给“改过的菜”换版本。每个包遵循 semver 语义化版本:breaking change 升 major,新功能升 minor,修 bug 升 patch。包内部通过 workspace 协议互相引用,发布时自动转换成对应的真实版本号,避免版本漂移——就像内部员工用工号交流,对外才亮出正式名片。

版本决策交给 Changesets:开发者在 PR 里附带一个变更集文件,写明哪个包变了、升几档、改了什么。发布时 Changesets 自动聚合所有变更集,批量 bump 版本、按依赖拓扑顺序生成 changelog,再调用发布命令把包依次发到 npm。依赖别人的包先发,被依赖的后发,顺序由拓扑排序保证。

只有真正改过的菜谱才需要重新印刷菜单。Changesets 是中央厨房的“变更记账本”,把每道菜的修改记录在案,上新日按账本自动执行,省去人工对账的混乱。

R=∑p∈P1[changed(p)]\displaystyle R = \sum_{p \in \mathcal{P}} \mathbf{1}[\text{changed}(p)]
需要发版的包数,等于“源码有变更”的包数,而不是仓库里的全部包——改多少发多少。
  1. 指示函数 \( \mathbf{1}[\cdot] \)括号里条件为真取 1,为假取 0,像一道“是否改过”的判断题。
    1[changed(p)]={1,p has changes0,otherwise\displaystyle \mathbf{1}[\text{changed}(p)] = \begin{cases} 1, & p \text{ has changes} \\ 0, & \text{otherwise} \end{cases}
  2. 对全部包求和把每个包“是否需要发版”的判断结果加起来,得到本次发布需要发新版本的包数量。
    R=∑p∈P1[changed(p)]\displaystyle R = \sum_{p \in \mathcal{P}} \mathbf{1}[\text{changed}(p)]

对比迁移考量:建不建中央厨房,先算回本账

是不是所有餐饮集团都该建中央厨房?不一定。建厨房要改造菜谱、培训厨师、重排动线,成本不低。Monorepo vs Polyrepo 的选择,本质是一笔投资回报账,关键看你的团队规模、协作方式和隔离需求。

Monorepo 的优势:跨包改动一次原子提交、依赖版本全局统一、重构跨包可见、缓存与 CI 全局复用。Polyrepo 的优势:仓库相互独立、权限隔离更细、故障爆炸半径小、团队自治伸缩自由。没有绝对优劣,只有匹配不匹配——小团队适合中央厨房的高效,大组织有时更需要分店自治的安全边界。

迁移风险也真实存在:工具链升级成本、团队习惯改变、CI 流水线改造、仓库变大后的 Git 性能问题。因此业界推荐渐进迁移:先引入 workspace 管理少量包,跑通依赖与缓存,再逐步把更多包迁入,最后统一发布流程——而不是一夜之间推倒重来。每一步都能回滚,风险被控制在单个批次内。

建不建中央厨房,关键看回本时间:投入几个月能换回每月省下的协调与构建时间,回本快就值得搬,回本慢就保持分店自治——这也是一种理性选择。

B=CmigrateSmonthly\displaystyle B = \frac{C_{\text{migrate}}}{S_{\text{monthly}}}
回本月数 = 迁移总成本 ÷ 每月节省的时间。回本快就值得搬,回本遥遥无期就保持分店自治。
  1. 迁移总成本 \( C_{\text{migrate}} \)改造工具链、培训团队、重写 CI 投入的总人月数,是搬迁厨房的“装修费”。
    Cmigrate=Ttools+Ttrain+Tci\displaystyle C_{\text{migrate}} = T_{\text{tools}} + T_{\text{train}} + T_{\text{ci}}
  2. 月度收益 \( S_{\text{monthly}} \)建成后每月省下的协作沟通与重复构建时间,是中央厨房带来的持续现金流。
    Smonthly=Cpoly−Cmono\displaystyle S_{\text{monthly}} = C_{\text{poly}} - C_{\text{mono}}
  3. 回本月数 \( B \)成本除以月收益,得到几个月能回本。这个简单的除法,是架构决策里最容易被忽略的理性标尺。
    B=CmigrateSmonthly\displaystyle B = \frac{C_{\text{migrate}}}{S_{\text{monthly}}}

AI 应用场景

电商中台多应用提速

阶段:前端工程化与构建优化

解决的问题:某电商公司有 12 个前端项目(商户端、H5、中台后台、组件库),以前是 12 个独立 Git 仓库,CI 全量构建要 40 多分钟,跨包改一个 bug 要开 2~3 个 PR 才能联动发布。传统多仓库模式下,依赖重复安装、构建无法复用,协作成本随仓库数平方级膨胀。

具体案例:团队用 pnpm workspace 把 12 个仓库合并为一个 Monorepo,组件库与业务包通过 workspace 协议互联,一次改动、一个 PR 原子提交;再引入 Turborepo 缓存,日常 CI 构建从 40 分钟降到 6 分钟以内,磁盘占用减少约 60%。

AI 辅助编码的构建链路

阶段:AI 生成代码后的持续集成反馈

解决的问题:AI 辅助编码会频繁修改单个包的源码,传统构建链路对改动无感知、全量重跑,开发者要等很久才能看到 AI 生成代码的测试结果。反馈慢会严重拉低“AI 结对编程”的迭代速度。

具体案例:开发者在 Monorepo 中用 AI 生成一个工具函数包,Turborepo 根据输入哈希只重跑该包及其下游依赖者的构建与测试,其余几十个包秒级命中缓存。AI 生成的每次改动,验证闭环从 10 分钟压缩到 1 分钟内。

开源组件库发布自动化

阶段:版本管理与自动化发布

解决的问题:多包开源库手工改版本号容易漏改、changelog 难以维护、发布顺序混乱。尤其在 AI 批量生成代码提交后,变更集散落在大量 PR 里,人工聚合几乎不可能。

具体案例:某组件库开源项目接入 Changesets 机器人,每个 PR 附带变更集文件,合并后自动聚合生成 changelog,按依赖拓扑顺序批量 bump 版本并发布到 npm。社区贡献者只需写一句“patch 修复了按钮颜色”,其余全部自动化。

Python 演示

python61 行
# -*- coding: utf-8 -*-"""模拟 Monorepo 中 pnpm 依赖去重与 Turborepo 增量缓存"""from hashlib import sha1 PACKAGE_SIZE = 5  # 每个第三方依赖包的体积单位  class Pkg:    def __init__(self, name, deps, code=''):        self.name = name        self.deps = deps        self.code = code  def npm_disk(pkgs):    """npm 模式, 每个包各自装一份全量依赖, 重复存储"""    return sum(len(p.deps) for p in pkgs) * PACKAGE_SIZE  def pnpm_disk(pkgs):    """pnpm 模式, 全局 store 只存一份, 按内容去重"""    unique = set()    for p in pkgs:        unique.update(p.deps)    return len(unique) * PACKAGE_SIZE  def turbo_build(pkgs):    """Turborepo 模式, 输入指纹未变的任务直接命中缓存跳过"""    cache = {}    cost = 0    for p in pkgs:        fingerprint = sha1((p.name + '|' + p.code).encode()).hexdigest()        if cache.get(p.name) == fingerprint:            print('  跳过 {}: 缓存命中'.format(p.name))            continue        print('  执行 {}: 缓存未命中'.format(p.name))        cost += 1        cache[p.name] = fingerprint    return cost  def main():    pkgs = [        Pkg('ui', ['react', 'lodash'], code='v1'),        Pkg('web', ['react', 'axios'], code='v1'),        Pkg('admin', ['react', 'lodash'], code='v1'),    ]    print('磁盘占用: npm={}, pnpm={}, 节省={}'.format(        npm_disk(pkgs), pnpm_disk(pkgs),        npm_disk(pkgs) - pnpm_disk(pkgs)))    print('第一次构建:')    first = turbo_build(pkgs)    pkgs[0].code = 'v2'  # 只改 ui 包源码    print('第二次构建 (仅 ui 包变更):')    second = turbo_build(pkgs)    print('任务耗时: 第一次={}s, 第二次={}s'.format(first, second))  if __name__ == '__main__':    main()
目的:用一个小型模拟程序演示 Monorepo 的两大核心收益——pnpm 的依赖去重让磁盘占用从“每包一份”变成“全局一份”,Turborepo 的输入指纹缓存让未变更的任务直接跳过。
  1. unique.update(p.deps)把所有包用到的依赖名合并进一个集合,天然去重。
  2. return len(unique) * PACKAGE_SIZE只按去重后的依赖种类计算磁盘占用,模拟 pnpm 全局共享 store。
  3. fingerprint = sha1((p.name + '|' + p.code).encode()).hexdigest()用“包名 + 源码内容”算出输入指纹,模拟 Turborepo 的任务哈希。
  4. if cache.get(p.name) == fingerprint:指纹一致说明输入没变,命中缓存,任务直接跳过。
  5. cache[p.name] = fingerprint任务执行完把新指纹写入缓存,供下次构建比对。
  6. pkgs[0].code = 'v2' # 只改 ui 包源码只修改一个包的源码,观察其余包如何命中缓存。

常见误区

误区我以为 Monorepo 就是把所有代码放进一个 Git 仓库,剩下的交给 Git 自动搞定。反正代码在一起了,依赖和构建问题都会自然消失。

正解Monorepo 只是第一步,真正的价值来自配套工具链:pnpm workspace 管依赖去重、Turborepo 管任务编排与缓存、Changesets 管发布。没有这些工具,单仓和多仓库相比,只是把杂乱从 N 个地方挪到了 1 个地方,构建反而可能更慢——就像把所有菜谱堆进一间屋子却不分类,找菜谱比从前更费劲。

为何常见:“单仓多包”这个概念听起来太简单,容易让人忽略工具链才是 Monorepo 的灵魂。

误区pnpm 用符号链接,节点的依赖关系像一堆“快捷方式”,是不是比 npm 的扁平 node_modules 更容易失效、更不可靠?

正解恰恰相反,符号链接是更严格的依赖管理:只有显式声明的依赖才能被访问,杜绝幽灵依赖;真实文件在 store 中只有一份,硬链接保证内容一致。npm 的 hoisting 扁平结构才会导致“能 import 但没声明”的隐患——那才是真正的脆弱。

为何常见:人们习惯把符号链接和日常“快捷方式失效”联系起来,而 pnpm 的链接由安装器统一管理,并不会随意失效。

误区Turborepo 的缓存只是让构建变快一点的小技巧,项目小的时候根本用不上,等以后再说吧。

正解缓存是 Monorepo 工程化的核心收益之一:CI 里大量重复构建(切分支、重跑流水线、同事拉代码)被缓存拦截,提速常在 70% 以上;配合远程缓存,整个团队的构建时间共享。它不是优化项,而是 Monorepo 规模化后的刚需。

为何常见:小项目感受不到缓存的价值,容易低估它在大型多包项目里“涓涓细流汇成河”的积累效应。

误区网上都说 Monorepo 很香,那 Polyrepo 就是过时的坏实践,应该把所有项目都迁进同一个仓库。

正解Monorepo 的优点是共享与原子变更,代价是集中与耦合;Polyrepo 的优点是隔离与自治,代价是协调成本。团队规模小、项目耦合深适合 Monorepo;团队大、权限隔离要求高,Polyrepo 反而更合适。没有银弹,只有匹配不匹配。

为何常见:技术社区容易一边倒地追捧新方案,让人忽略了它并不适用所有场景。

误区迁移 Monorepo 就得挑一个周末把所有仓库合并完,否则新旧并存会更混乱,长痛不如短痛。

正解主流做法是渐进迁移:先建一个 Monorepo 壳,用 workspace 引入一两个包,跑通依赖、缓存、发布流程后,再按批次迁入更多包。一次性重写风险极大,而渐进迁移可以把风险控制在每个批次内,随时可以回滚。

为何常见:人们容易把迁移想象成“切换开关”,实际上它更像“装修期间分店照常营业”——边营业边改造才是常态。

面试题

Q1. 关于 Monorepo 的说法,哪一项是正确的?

单选题 · easy

  • A.一个仓库仅容纳单一项目
  • B.一个仓库统一管理多个包← 正确答案
  • C.必须配合 Git 子模块使用
  • D.与 Polyrepo 概念完全等价

答案:B。Monorepo 就是“一个仓库管理多个包”,对应中央厨房里所有菜谱共用一本菜单。A 说一仓一项目是 Polyrepo 的思路;C 的 Git 子模块是另一个工具,和 Monorepo 无关;D 说两者等价显然是错的。

Q2. pnpm 节省磁盘空间的关键机制是什么?

单选题 · easy

  • A.安装时对文件进行二次压缩
  • B.全局唯一 Store 实现依赖去重← 正确答案
  • C.安装过程完全不需要访问网络
  • D.每个依赖包强制使用相同版本

答案:B。pnpm 的杀手锏是全局唯一 store:真实依赖只存一份,项目里用链接引用,像酱油只买一份大家共用。A 的压缩是 npm 也会做的常规手段;C 与 pnpm 机制无关;D 限制版本是另一回事。

Q3. 关于 pnpm 符号链接的说法,哪些是正确的?

多选题 · mid

  • A.真实文件集中存放于全局 Store← 正确答案
  • B.项目 node_modules 内放置软链接← 正确答案
  • C.每个包都会复制一份真实文件
  • D.符号链接无法进行权限控制

答案:AB。pnpm 把真实文件放 store,node_modules 里放软链接,这就是“一个真身、多张标签”。C 是 npm 的复制模式;D 说无法权限控制也错——链接由安装器统一管理,能否访问取决于包是否声明了该依赖。

Q4. Turborepo 中 dependsOn 的核心作用是什么?

单选题 · mid

  • A.明确各任务之间的执行先后顺序← 正确答案
  • B.自动管理第三方依赖的版本号
  • C.删除缓存以强制重新执行任务
  • D.将打包产物自动上传到服务器

答案:A。dependsOn 是任务管道里声明“前置任务”的字段,像总调度手里的工序表:先 lint 再 build 后 test。B 是版本管理的事;C 是缓存清理;D 是部署,都不是 dependsOn 的职责。

Q5. Turborepo 判断缓存是否命中的依据是什么?

单选题 · mid

  • A.依赖文件的最近修改时间戳
  • B.输入文件的哈希指纹是否一致← 正确答案
  • C.Git 提交的作者名和提交信息
  • D.包在仓库中的物理存放位置

答案:B。Turborepo 用输入指纹(源码、依赖、环境变量等哈希)判断缓存是否命中,相当于按“菜谱和食材是否变了”决定要不要重炒。修改时间不稳定、作者与位置与构建结果无关,都不能作为依据。

Q6. Turborepo 的缓存可以存放于哪些位置?

多选题 · hard

  • A.本地磁盘中的 .turbo 缓存目录← 正确答案
  • B.远程共享缓存服务如 Vercel← 正确答案
  • C.浏览器本地缓存以加快加载
  • D.Git 仓库的提交对象数据库

答案:AB。Turborepo 缓存有本地(.turbo 目录)和远程(Vercel Remote Cache 等共享服务)两类,像成品柜既放本厨房也放中央仓库。浏览器缓存和 Git 对象库都不参与任务缓存。

Q7. Changesets 在发布流程中的主要用途是什么?

单选题 · mid

  • A.维护变更集并生成版本更新日志← 正确答案
  • B.替代 Git 进行代码版本控制
  • C.压缩前端 JavaScript 构建产物
  • D.自动备份数据库中的用户数据

答案:A。Changesets 是发布流程的“变更记账本”:收集每个 PR 的变更集,自动聚合 changelog 并批量升版本。它不管代码版本控制(那是 Git 的事)、不压缩产物、更不备份数据库。

Q8. 相比 Polyrepo,Monorepo 的优势包括哪些?

多选题 · hard

  • A.跨包改动可合并为一次原子提交← 正确答案
  • B.依赖版本集中管理降低不一致← 正确答案
  • C.各仓库拥有更细粒度的权限隔离
  • D.单个仓库代码量更少更易维护

答案:AB。Monorepo 的两大优势正是原子提交和统一依赖,像一个厨房里改菜谱全局同步。权限隔离恰恰是 Polyrepo 的优势;单仓库代码量更少也不符合 Monorepo 实际——它更大了。

Q9. 从 Polyrepo 渐进迁移到 Monorepo,正确做法是什么?

单选题 · hard

  • A.一夜之间删除旧仓库并推倒全部重写
  • B.先迁移少量包并引入 Workspace 磨合← 正确答案
  • C.要求所有包必须同版本同频率发布
  • D.关闭代码审查直接合并全部变更

答案:B。渐进迁移是行业共识:先用 workspace 引入几个包跑通流程,再批次迁入,像装修期间分店照常营业。A 的一次性重写、C 的强制同版发布、D 的关闭审查都是高风险做法。

Q10. 在 pnpm 严格隔离的依赖结构下,一个包能直接 import 的依赖是哪些?

单选题 · extreme

  • A.自身 package 文件显式声明的依赖← 正确答案
  • B.兄弟包声明而自身未声明的依赖
  • C.根目录 node_modules 中的所有依赖
  • D.全局 Store 中存放的全部依赖包

答案:A。pnpm 严格隔离下,只有自己 package.json 声明过的依赖能 import,杜绝幽灵依赖——你没点的菜不会端上来。兄弟包的私有依赖、根目录未声明依赖、全局 store 里的包,都不能直接访问。