做全栈这五年,我见过不少团队在微服务、多仓库(Polyrepo)和单体大仓(Monorepo)之间反复横跳。
很多团队最初把前后端拆成两个仓库,看似职责分明,但业务跑上两三年就会遇到几个典型痛点:后端改了一个接口字段,前端上线前全靠口头对齐;公共工具函数在前端和后端复制粘贴了两份,修了一个 bug 忘了同步另一个;CI 流水线每次都要全量跑一遍测试和打包,部署耗时越来越长。
Monorepo 不是银弹,它带来了仓库体积膨胀、权限难以细分等心智负担。但如果你所处的阶段是核心业务快速迭代、前后端协作紧密,那么用一套合理的技术栈把它搭起来,收益往往大于成本。
今天聊聊我目前在生产环境跑得比较顺畅的一套组合:pnpm + Turborepo。我们不聊虚的概念,只聚焦三件事:目录与边界、前后端代码真正共享什么、以及 CI 缓存怎么治理。
一、 目录组织:业务应用与基础能力的解耦
很多 Monorepo 项目最容易犯的错误是“过度拆分”。把一个十几行的工具函数单独拆一个 package,最后配置复杂度陡增,维护体验极差。
我习惯把仓库分为 apps 和 packages 两层,原则是:apps 负责组装和部署,packages 负责被消费且不包含业务状态。
一个典型的全栈结构如下:
.
├── apps
│ ├── web # 前端应用 (Next.js / Vite)
│ └── api # 后端服务 (NestJS / Hono / Fastify)
├── packages
│ ├── contracts # 前后端共享:接口契约、Zod Schema、TS 类型
│ ├── utils # 纯函数工具库
│ ├── tsconfig # 共享的 TypeScript 配置
│ └── eslint-config # 共享的规范配置
├── pnpm-workspace.yaml
├── turbo.json
└── package.json
在根目录的 pnpm-workspace.yaml 中,简单明确地声明这两类目录:
packages:
- 'apps/*'
- 'packages/*'
二、 核心收益:前后端共享的不应只是“类型”
很多人以为 Monorepo 的前后端共享就是共享 interface。但 TypeScript 的类型在编译后会被完全擦除,如果只有静态类型,运行时该崩还是会崩。
真正有价值的共享,是“运行时校验 Schema + 静态类型推导”的一体化。 我们通常采用 Zod 或 TypeBox 来实现。
以 packages/contracts 为例:
1. 定义共享的 Contract
// packages/contracts/src/user.ts
import { z } from 'zod';
// 1. 运行时校验 Schema
export const CreateUserSchema = z.object({
email: z.string().email('邮箱格式不正确'),
nickname: z.string().min(2, '昵称至少 2 个字符').max(20),
role: z.enum(['admin', 'member']).default('member'),
});
// 2. 从 Schema 推导静态类型
export type CreateUserInput = z.infer<typeof CreateUserSchema>;
2. 前端消费:表单校验
前端在表单提交前,直接复用该 Schema 进行拦截,报错信息完全一致:
// apps/web/src/components/RegisterForm.tsx
import { CreateUserSchema, CreateUserInput } from '@repo/contracts';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
export const RegisterForm = () => {
const { register, handleSubmit, formState: { errors } } = useForm<CreateUserInput>({
resolver: zodResolver(CreateUserSchema),
});
const onSubmit = (data: CreateUserInput) => {
// 调用 API
};
// ...
};
3. 后端消费:请求体入参验证
后端接口框架(这里以 Hono 为例,NestJS 或 Express 同理)直接挂载中间件:
// apps/api/src/routes/user.ts
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { CreateUserSchema } from '@repo/contracts';
const app = new Hono();
app.post('/users', zValidator('json', CreateUserSchema), async (c) => {
// 此时 data 的类型已被推断为 CreateUserInput,且已完成运行时校验
const data = c.req.valid('json');
// 执行落库逻辑...
return c.json({ success: true, data });
});
后端一旦修改了字段约束(例如昵称最小长度改成 3),TypeScript 编译器会立即在前端项目抛出报错,CI 阶段就会被卡住,直接把低级联调问题扼杀在代码提交前。
三、 构建策略权衡:JIT 源码引入还是预构建?
在配置内部包依赖时,通常有两种路线:
- 预构建(Build-first):每个 internal package 都有自己的
tsup或tsc脚本,修改后必须 build 才能被引用。 - 源码引用(Just-in-Time):应用直接读取 packages 的 TypeScript 源码,由应用的构建工具(Next.js / Vite / TSX)统一编译。
如果团队规模在 20 人以内,项目数量不多,我更倾向于源码直接引用。这样改动共享库代码时,前端热更新能直接生效,不需要额外的 watch 构建进程,开发心智负担最低。
在 packages/contracts/package.json 中配置:
{
"name": "@repo/contracts",
"version": "0.0.1",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
现代框架普遍支持 Monorepo 源码引入。比如 Next.js 只需在 next.config.js 配置 transpilePackages: ['@repo/contracts'] 即可。
四、 Turborepo 管道设计与 CI 缓存治理
代码放在一个仓库后,CI 耗时会呈线性增长。如果一个 PR 仅仅改了前端页面的文案,把后端集成测试、Docker 构建再跑一遍是毫无意义的。
Turborepo 的核心价值就在于:有向无环图(DAG)编排 + Hash 缓存感知。
1. 声明清晰的 turbo.json
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["src/**", "package.json", "tsconfig.json"],
"outputs": ["dist/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["^build"],
"inputs": ["src/**", "**/*.test.ts"]
},
"lint": {
"dependsOn": ["^topo"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
这里几个细节值得注意:
^build意味着该任务依赖上游 package 的 build 产物。- 明确声明
inputs,避免无关文件(比如某个 markdown 变更)导致全局缓存失效。 - 明确排除不该被持久化的缓存,比如
.next/cache/**。
2. CI 流程的精细化控制
在 GitHub Actions 或 GitLab CI 中,可以结合 Turborepo 的 filter 命令,只构建受影响的项目:
# GitHub Actions 示例片段
- name: Run checks on affected projects
run: pnpm turbo run lint test build --filter=...[origin/main]
--filter=...[origin/main] 会计算当前分支与 main 分支的差异,不仅只构建改动的 package,还会自动包含依赖它的下游应用。
3. 远端缓存(Remote Cache)的取舍
如果只是本地开发,Turborepo 的本地磁盘缓存已经够用。但在团队协作场景下,开发者 A 构建过的产物,开发者 B 和 CI 服务器依然需要重新跑一遍,这时候 Remote Cache 就能省下大量时间。
如果你的团队无法接入 Vercel 提供的托管缓存,可以考虑在内网自建一个轻量的 Remote Cache 服务(例如使用开源的 ducktors/turborepo-remote-cache,底层挂载 S3 或 MinIO 存储)。配置起来只需要几行环境变量:
TURBO_API="https://your-internal-cache-service.com"
TURBO_TOKEN="your-secret-token"
有了远端缓存后,CI 任务命中缓存时,耗时通常能从 5-8 分钟压减到 30 秒以内。
尾声:做减法的心态
在搭建全栈 Monorepo 时,最忌讳的是把所有工具链全上一遍:Nx、Bazel、微前端、复杂的 Changesets 发版机制……最后团队里没人搞得懂发版流程。
pnpm 处理了依赖软链和幽灵依赖问题,Turborepo 解决了跨项目的任务调度和缓存。这两者的结合属于典型的轻量级方案,配置代码少,侵入性低。
先让团队享受到“改动类型后前后端立即同步”的确定性,再通过 CI 缓存把构建速度稳住。当业务真的扩张到几十个应用时,再去考虑更重的工程治理也不迟。工具永远是服务于交付的,能把简单的事情做好,系统就稳定了一大半。
License: CC BY-NC 4.0
Updated 13 hours ago
Was this article helpful? Give it a like.
0 comments


