单仓库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 还能让以下内容变成单一来源:

  1. Zod schema——后端的请求校验和前端表单的校验共用同一份 schema,改一处同时生效
  2. 常量与枚举——状态码、角色名、权限标识,放一个地方不容易漂移
  3. 工具函数——日期格式化、数据转换这类纯函数,各自维护迟早会出现行为不一致

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-minivalibot

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

迁移分五步:

  1. 建立骨架——创建 monorepo 目录、pnpm-workspace.yaml、根 tsconfig.json
  2. 搬移代码——后端仓库移入 apps/api,前端仓库移入 apps/web(可以用 git merge --allow-unrelated-histories 保留历史,也可以直接 cp -r
  3. 提取共享代码——前端手写的 API 类型搬到 packages/shared/src/types/,后端 Zod schema 搬到 packages/shared/src/schemas/
  4. 替换引用——./types/api@repo/shared/types./schemas/user@repo/shared/schemas
  5. 验证——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。

延伸阅读

总结

Hono RPC 的类型共享机制和 monorepo 的配合可以归纳为三个层次:

  1. 类型层。shared 包导出类型和 Zod schema,前后端通过 import 共享同一个类型来源,接口变更在编译期就能被感知
  2. 构建层。TypeScript project references 和 Turborepo 配合,让类型检查和构建按依赖顺序执行,同时通过缓存避免重复编译
  3. 工程层。CI 流水线的变更检测、选择性部署和版本管理,让 monorepo 在日常开发中不成为负担

monorepo 的核心价值是「单一来源」——类型、校验规则、常量只有一个地方可以改,改完所有消费方自动同步。但引入 monorepo 工具链本身也有成本,团队在决定采用之前应该先评估项目的共享代码量和变更频率。

下一篇讲 API 类型共享规范——当共享的类型和 schema 数量增长到一定规模之后,怎样组织命名、版本和导出结构来保持可维护性。