单仓库Monorepo实践
要点
- Hono RPC 的类型共享机制天然适合 monorepo 结构,前后端类型导入只靠 import 就能完成
- 合理的目录分工是 monorepo 能否长期维持的关键:apps/ 放应用,packages/ 放共享代码
- Zod schema 可以同时承担后端校验和前端类型推导,但需要处理好运行时依赖
- TypeScript project references 能在大型 monorepo 中明显缩短类型检查耗时
- 构建顺序、路径别名、测试策略和 CI/CD 流水线都围绕「依赖方向」来设计
- monorepo 并非适合所有项目——团队规模小、接口变化不频繁时,单仓库反而增加复杂度
1. 为什么 Hono RPC 适合放在 monorepo
03 里提到过,Hono 的 RPC 客户端需要从后端路由定义中导出类型,前端通过 import type 直接引用。这意味着前后端代码之间存在一条类型依赖——前端必须在编译期访问到后端的类型定义。
这条依赖在两个独立仓库里也能解决:把类型发布成 npm 包,前端安装后使用。但代价是每次后端改了接口,都要重新发包、前端更新版本号、重新安装。放到同一个仓库里,这条类型依赖就变成了仓库内部的文件引用,后端改了路由定义,前端的 TypeScript 编译马上就能感知到变化。
除了类型共享,monorepo 还能让以下内容变成单一来源:
- Zod schema——后端的请求校验和前端表单的校验共用同一份 schema,改一处同时生效
- 常量与枚举——状态码、角色名、权限标识,放一个地方不容易漂移
- 工具函数——日期格式化、数据转换这类纯函数,各自维护迟早会出现行为不一致
2. 目录结构选项
monorepo 的目录结构取决于团队规模和项目复杂度。
方案一:扁平结构
monorepo/
├── api/ # Hono 后端
├── web/ # 前端应用
├── shared/ # 共享类型和 schema
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.json
适合两三个人的小团队,结构简单。
方案二:apps + packages(推荐)
monorepo/
├── apps/
│ ├── api/ # Hono 后端
│ ├── web/ # 前端应用
│ └── admin/ # 管理后台(可选)
├── packages/
│ ├── shared/ # 共享类型、schema、常量
│ ├── ui/ # 共享 UI 组件
│ └── config/ # 共享配置
├── package.json
├── pnpm-workspace.yaml
└── tsconfig.json
apps/ 放可部署的应用,packages/ 放不能独立运行的共享代码。构建和部署时只需要关注 apps/ 下的内容。大多数 Hono RPC 项目用这个方案就够了。
方案三:按领域划分
当共享代码量大到一定程度,可以按业务领域拆分 packages(如 packages/user/、packages/billing/),让依赖关系更清晰。这个方案在共享代码超过几千行、涉及多个独立业务域时再考虑。
3. 搭建共享类型包
以方案二为例,shared 包的核心工作是导出类型和 schema。
// packages/shared/package.json
{
"name": "@repo/shared",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./schemas": "./src/schemas/index.ts",
"./types": "./src/types/index.ts"
},
"dependencies": {
"zod": "^3.23.0"
}
}private: true 表示只在仓库内部使用。exports 按功能模块分了子路径,前端可以按需引入:
// apps/web/src/api/client.ts
import { createUserSchema } from '@repo/shared/schemas'
import type { User } from '@repo/shared/types'src 目录按职责划分:schemas/ 放 Zod schema(user.ts、common.ts),types/ 放 TypeScript 类型(user.ts、api.ts),constants/ 放常量(roles.ts),index.ts 作为统一导出入口。
4. 共享 Zod schema
Zod schema 在 Hono RPC 项目里有一个特殊位置:它既是后端的运行时校验规则,又是前端类型推导的来源。
// packages/shared/src/schemas/user.ts
import { z } from 'zod'
export const createUserSchema = z.object({
name: z.string().min(1, '名称不能为空'),
email: z.string().email('邮箱格式不正确'),
role: z.enum(['admin', 'member', 'viewer']),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export const userResponseSchema = z.object({
id: z.number(),
name: z.string(),
email: z.string(),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.string(),
})
export type UserResponse = z.infer<typeof userResponseSchema>后端直接 import 这个 schema 传给 validator:
// apps/api/src/routes/users.ts
import { zValidator } from '@hono/zod-validator'
import { createUserSchema } from '@repo/shared/schemas'
const users = new Hono()
.post('/', zValidator('json', createUserSchema), async (c) => {
const data = c.req.valid('json')
// data 的类型是 CreateUserInput
return c.json({ user: { id: 1, ...data, createdAt: new Date().toISOString() } }, 201)
})前端在表单组件里也能复用同一份 schema:
// apps/web/src/components/create-user-form.tsx
import { createUserSchema } from '@repo/shared/schemas'
function validateForm(values: CreateUserInput) {
const result = createUserSchema.safeParse(values)
if (!result.success) {
return result.error.flatten().fieldErrors
}
return {}
}后端改了校验规则(比如 name 最小长度从 1 改成 2),前端校验自动同步。需要注意 shared 包引入了 zod 依赖,前端也会把 zod 打包进去(约 50KB,gzip 后约 13KB)。对包体积敏感的场景可以考虑 zod/v4-mini 或 valibot。
5. TypeScript 项目引用
当 monorepo 的包数量变多,TypeScript 类型检查的耗时会明显增加。project references 让 tsc 能按包逐个检查,只重新编译变化的部分。
shared 包需要开启 composite:
// packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationMap": true,
"composite": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src"]
}composite: true 要求项目生成 .d.ts 声明文件。declarationMap: true 让 IDE 的「跳转到定义」能直接跳到源码。
apps 通过 references 声明依赖:
// apps/api/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"paths": {
"@repo/shared": ["../../packages/shared/src"],
"@repo/shared/*": ["../../packages/shared/src/*"]
}
},
"references": [{ "path": "../../packages/shared" }],
"include": ["src"]
}根目录 tsconfig 聚合所有包:
// tsconfig.json(根目录)
{
"files": [],
"references": [
{ "path": "packages/shared" },
{ "path": "apps/api" },
{ "path": "apps/web" }
]
}运行 tsc --build 时,TypeScript 会按依赖顺序编译。只改了 api 的代码时,shared 不会重新编译。
6. 构建顺序与依赖管理
monorepo 里的构建顺序必须遵循包的依赖关系——shared 先于 api 和 web。
pnpm workspace
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'// apps/api/package.json
{
"dependencies": {
"@repo/shared": "workspace:*",
"hono": "^4.0.0"
}
}workspace:* 告诉 pnpm 这个依赖指向仓库内的包,pnpm install 时自动建立 symlink。
Turborepo 编排
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"typecheck": { "dependsOn": ["^build"] },
"dev": { "persistent": true, "cache": false }
}
}dependsOn: ["^build"] 的意思是:构建当前包之前,先构建它的所有依赖。Turborepo 还会缓存构建结果——shared 代码没变时,再次构建直接命中缓存。
如果不想引入 Turborepo,pnpm -r --stream run build 也能按拓扑排序构建,只是缺少缓存。
7. 路径别名与模块解析
通过 workspace:* 依赖和 TypeScript paths 配合,用包名就能引用,代码里不出现相对路径:
// apps/web/src/api/client.ts
// ✅ 用包名引用
import { createUserSchema } from '@repo/shared/schemas'
// ❌ 不要绕过 exports 直接引用内部文件
// import { something } from '@repo/shared/src/internal/utils'如果用 Vite,可能需要在 vite.config.ts 里加 alias:
// apps/web/vite.config.ts
export default defineConfig({
resolve: {
alias: { '@repo/shared': '../../packages/shared/src' },
},
})绕过 exports 直接引用内部路径会破坏包的封装——一旦内部目录调整,所有引用方都要改。通过包的公开入口引用,内部怎么调整都不影响。
8. 测试策略
monorepo 的测试要区分两层:
- shared 包:单元测试为主——schema 校验、工具函数行为,运行快、不依赖外部环境
- apps:集成测试为主——API 端到端行为、前端组件渲染
测试配置可以抽成共享包:
// packages/config/vitest.shared.ts
import { defineConfig } from 'vitest/config'
export function sharedVitestConfig() {
return defineConfig({ test: { globals: true, environment: 'node' } })
}各 app 引用这个配置后覆盖自己的特定项。运行测试用 turbo run test,Turborepo 会并行运行无依赖关系的包的测试。
9. CI/CD 注意事项
monorepo 的 CI 和单仓库的区别:不需要每次提交都构建和部署所有应用。
Turborepo 的缓存机制会让没有代码变更的包直接跳过构建。基础的 CI 配置:
# .github/workflows/ci.yml
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: 'pnpm' }
- run: pnpm install --frozen-lockfile
- run: pnpm turbo run typecheck test build部署环节可以判断哪些应用有变更再决定:pnpm turbo run build --filter='...[HEAD^]' 列出受影响的包。
版本管理方面,shared 包不需要独立发布版本号时保持 "version": "0.0.0" 就行。如果需要发布 npm 包,推荐用 Changesets 管理版本和 changelog。
10. monorepo 不适合的场景
monorepo 的价值在以下条件同时成立时比较明显:共享代码量持续增加、接口变更频率高、团队至少两人以上。以下条件命中任一时,monorepo 可能投入大于收益:
- 只有一个前端对接后端——搭建 workspace + Turborepo + project references 的维护成本可能超过收益
- 前后端发布节奏完全不同——共享包每改一次就要重新构建前端,链路太长
- 一人团队、早期探索阶段——代码结构还没稳定,过早引入工具链增加切换成本
替代方案:
- npm 私有包——共享代码量不大但发布节奏不同时适用
- Git submodule / subtree——隔离性更好,但配置复杂
- OpenAPI 代码生成——跨团队、跨技术栈时更灵活
11. 从分离仓库迁移到 monorepo
迁移分五步:
- 建立骨架——创建 monorepo 目录、
pnpm-workspace.yaml、根tsconfig.json - 搬移代码——后端仓库移入
apps/api,前端仓库移入apps/web(可以用git merge --allow-unrelated-histories保留历史,也可以直接cp -r) - 提取共享代码——前端手写的 API 类型搬到
packages/shared/src/types/,后端 Zod schema 搬到packages/shared/src/schemas/ - 替换引用——
./types/api→@repo/shared/types,./schemas/user→@repo/shared/schemas - 验证——
pnpm install && pnpm typecheck && pnpm test && pnpm build
整个过程不需要一次性完成。每提取一个模块就运行一次 typecheck,确保没有引入类型错误。
12. 完整示例:最小 monorepo
把所有配置汇总到一个可直接运行的结构:
hono-monorepo/
├── apps/
│ ├── api/src/index.ts # Hono 后端入口
│ └── web/src/client.ts # 前端客户端
├── packages/
│ └── shared/src/
│ ├── schemas/user.ts # Zod schema
│ └── types/user.ts # TypeScript 类型
├── package.json # turbo run build / typecheck / test
├── pnpm-workspace.yaml
├── tsconfig.json # project references 聚合
└── turbo.json # 构建任务编排
后端入口:
// apps/api/src/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { createUserSchema } from '@repo/shared/schemas'
import type { UserResponse } from '@repo/shared/types'
const app = new Hono()
const route = app
.get('/users', async (c) => {
return c.json({ users: [] as UserResponse[] })
})
.post('/users', zValidator('json', createUserSchema), async (c) => {
const data = c.req.valid('json')
return c.json({ user: { id: 1, ...data, createdAt: new Date().toISOString() } }, 201)
})
export type AppType = typeof route
export default app前端客户端:
// apps/web/src/client.ts
import { hc } from 'hono/client'
import type { AppType } from '../../api/src'
const client = hc<AppType>('http://localhost:8787')
const res = await client.users.$post({
json: { name: 'Alice', email: '[email protected]', role: 'member' },
})
// role 写成 'superadmin' 时 TypeScript 编译会报错这个结构从第一天就能用,后续随业务增长再逐步添加 packages。
延伸阅读
- 03-Hono Client使用方式 — hc 客户端的详细用法和类型推导机制
- 04-路由定义与类型导出 — 后端侧路由定义如何影响客户端类型
- Hono RPC 文档 — Hono 官方 RPC 指南
- Turborepo 文档 — monorepo 构建系统的配置和缓存机制
- TypeScript Project References — composite 和 references 的说明
- pnpm Workspace — pnpm 工作区配置和 workspace 协议
总结
Hono RPC 的类型共享机制和 monorepo 的配合可以归纳为三个层次:
- 类型层。shared 包导出类型和 Zod schema,前后端通过
import共享同一个类型来源,接口变更在编译期就能被感知 - 构建层。TypeScript project references 和 Turborepo 配合,让类型检查和构建按依赖顺序执行,同时通过缓存避免重复编译
- 工程层。CI 流水线的变更检测、选择性部署和版本管理,让 monorepo 在日常开发中不成为负担
monorepo 的核心价值是「单一来源」——类型、校验规则、常量只有一个地方可以改,改完所有消费方自动同步。但引入 monorepo 工具链本身也有成本,团队在决定采用之前应该先评估项目的共享代码量和变更频率。
下一篇讲 API 类型共享规范——当共享的类型和 schema 数量增长到一定规模之后,怎样组织命名、版本和导出结构来保持可维护性。