monorepo 是什么?pnpm workspace 和 Turborepo 怎么用?
一句话回答
monorepo 是把多个相关的项目和包放在同一个仓库里管理,好处是代码共享方便、跨包修改可以在一次提交里完成、工具链和规范统一;代价是仓库变大,构建和权限管理更复杂。pnpm workspace 解决包之间的链接:用 workspace: 协议引用本地包,安装时直接链接源码目录。Turborepo 解决任务编排:按依赖拓扑顺序运行任务,根据输入计算哈希来缓存结果,没改动的包直接复用,还支持远程缓存让团队和 CI 共享。发版通常配合 changesets 管理版本号和变更日志。
详细解析
monorepo 和多仓库
| monorepo | 多仓库(polyrepo) | |
|---|---|---|
| 代码共享 | 直接引用本地包,改完立即生效 | 先发布到 npm,再到各仓库升级版本 |
| 跨项目修改 | 一次提交、一个 PR | 多个仓库分别提交,按顺序发布 |
| 依赖和工具链 | 统一版本、统一配置 | 各自维护,容易不一致 |
| 构建和 CI | 需要增量构建,否则越来越慢 | 每个仓库各自独立,规模小 |
| 权限 | 粒度粗,要靠 CODEOWNERS 等方式控制 | 可以按仓库分配权限 |
适合 monorepo 的情况:组件库、工具包和使用它们的应用由同一个团队维护,经常需要一起修改。团队之间相互独立、发布节奏差异很大时,多仓库反而更省事。
pnpm workspace
- 在根目录的
pnpm-workspace.yaml里声明哪些目录是子包 - 子包之间用
workspace:协议声明依赖,例如"@demo/utils": "workspace:*",pnpm 只会从本地工作区解析它,在node_modules中创建指向源码目录的符号链接 - 执行
pnpm publish时,workspace:*会被替换成被依赖包当时的具体版本,workspace:^替换成^加版本号,发布出去的包不会带着workspace: - 用
--filter选择要操作的包:pnpm --filter web dev、pnpm --filter @demo/utils add dayjs;pnpm -r build在所有包中执行,并且按依赖顺序执行
公共的开发依赖(TypeScript、ESLint)可以用 pnpm add -Dw 装在根目录。pnpm 的严格依赖结构能避免子包之间互相"借用"没有声明的依赖,见 幽灵依赖。
任务编排与缓存:Turborepo 的思路
包一多,"每次全量构建"就不可接受了。Turborepo、Nx 这类工具的核心思路一致:
- 依赖拓扑:根据各包
package.json里的依赖关系建出任务图,dependsOn: ["^build"]表示先构建它依赖的包,没有依赖关系的任务并行执行 - 内容哈希缓存:把源码文件、依赖包的哈希、环境变量、任务配置一起算出一个哈希。哈希和之前一致就跳过执行,直接还原
outputs里的产物和日志 - 增量构建:只改了
packages/utils,就只重新构建它和依赖它的包;--filter、--affected可以只运行受影响的包 - 远程缓存:缓存存到远端,同事和 CI 都能命中同一份结果。Turborepo 用
turbo login和turbo link连接远程缓存,也可以自建兼容的缓存服务
版本发布:changesets
- 每次提交需要发版的改动时,执行
pnpm changeset,选择受影响的包和升级类型(major / minor / patch),写一句变更说明,生成一个.changeset/*.md文件,随代码一起提交 - 发版时执行
pnpm changeset version:消费所有 changeset 文件,升级版本号、更新依赖它的内部包、写入CHANGELOG.md - 执行
pnpm changeset publish:把版本号高于 npm 上已有版本的包发布出去,并打上 git tag
在 CI 中通常用 changesets 提供的 GitHub Action 自动开一个"版本 PR",合并后再自动发布。
代码示例
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
{
"$schema": "https://turborepo.dev/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"test": {
"dependsOn": ["build"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
上面是根目录的 turbo.json(Turborepo 1.x 中这个字段叫 pipeline,2.x 改名为 tasks)。常用命令:
pnpm turbo run build # 按拓扑顺序构建所有包,命中缓存的直接跳过
pnpm turbo run build --filter=web... # 只构建 web 及它依赖的包
pnpm turbo run test --affected # 只运行当前分支改动影响到的包
面试官可能追问
Turborepo 的缓存什么时候会失效?命中错了怎么办?
输入的哈希变了就失效:包内的源码文件、依赖包的哈希、turbo.json 中这个任务的配置、声明过的环境变量。最常见的"错误命中"是构建结果依赖某个环境变量,却没有在 env 里声明,结果不同环境复用了同一份产物。另一种是 outputs 写漏了,缓存命中后产物不完整。排查时用 --dry 查看任务的哈希和输入,必要时用 --force 跳过缓存。
子包要不要先构建才能被其他包使用?
两种做法都常见。一种是子包的 main 指向构建后的 dist,依赖方使用前必须先构建,靠 dependsOn: ["^build"] 保证顺序。另一种是"内部包"直接指向源码,由应用的构建工具一起编译,省掉了子包的构建步骤,开发时改了立即生效,但要求应用的构建配置能处理子包的源码(TypeScript、JSX 等)。
Turborepo 和 Nx 有什么区别?
核心都是任务图加缓存。Turborepo 配置少,主要做任务编排和缓存,上手快。Nx 功能更多:代码生成器、按框架提供的插件、依赖关系可视化、模块边界约束等,适合规模更大、需要更强治理能力的仓库。只用到任务缓存时,两者差别不大。
易错点
workspace:*只在工作区内有效,用 npm 直接发布不会被替换,要用pnpm publish或 changesetsturbo.json的outputs写漏,缓存命中后产物缺失;dev这类常驻任务忘了设置cache: false- 以为 monorepo 就是"所有代码放在一个仓库里",没有任务缓存和依赖边界,仓库越大 CI 越慢
- 根目录的依赖被子包直接引用而没有在子包里声明,子包单独发布后就找不到依赖
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。