1. 概述
在 api 子站中,为了防止当接口变多之后,文件变多会不好维护,我们需要提前约定一种代码组织结构。在前面的基础知识中,我们也提到了这一点
目前我们还是把接口直接堆在 apps/api/src/app.ts 里,web 侧也是在 page.tsx 里直接写 hc<AppType>() 和请求逻辑。这肯定是不合理的
如果继续这样任由其发展,后面很快会出现几个结果:
- 所有 api route 都挤在一个文件里
- 全局错误处理和具体业务实现混在一起
- 想找某个接口时,要在整份
app.ts里来回滚 AppType虽然还能导出,但维护成本会越来越高
我们可以先模拟一大堆 api route,然后看看应该怎么组织代码
核心的方式就是:
- API 按业务域拆 route
- web 按页面和 api.ts 分层消费接口
2. api 子站应该怎么按域拆 route
api 子站这边最合适的做法,是保留 apps/api/src/app.ts 负责 app 级职责,把具体接口拆进 routes/。
也就是说,app.ts 只做这些事:
new Hono()onErrornotFound- 挂载所有子路由
- 导出
AppType
而具体 route 则拆成这种结构:
这种拆法的关键不是「为了拆而拆」,而是先按业务域把接口聚在一起。
- system 放探活和系统类接口
- catalog 放列表类接口
- user 放用户类接口
- order 放订单类接口
一旦目录按域稳定下来,后面 route 数量再多,也不会继续把所有逻辑挤回 app.ts。
4. routes/index.ts
拆完 route 文件之后,还需要一个统一挂载入口,也就是 apps/api/src/routes/index.ts。
它的职责很单纯:把各域路由挂到总 app 上。
01import { Hono } from 'hono'02import catalogRoute from './catalog/list.route'03import orderRoute from './order/detail.route'04import healthRoute from './system/health.route'05import pingRoute from './system/ping.route'06import userRoute from './user/profile.route'0708type Bindings = {09APP_ENV: 'development' | 'test' | 'production'10}1112const routes = new Hono<{ Bindings: Bindings }>()1314const appRoutes = routes15.route('/health', healthRoute)16.route('/rpc/system/ping', pingRoute)17.route('/rpc/catalog', catalogRoute)18.route('/rpc/user', userRoute)19.route('/rpc/order', orderRoute)2021export type RoutesType = typeof appRoutes2223export default appRoutes
这里有两个好处。
第一,挂载关系集中可见。
要看整个 API 暴露了哪些入口,不需要翻所有 route 文件,看 routes/index.ts 就够了。
第二,app.ts 会非常干净。
app.ts 不再堆积每条 route 的实现,只保留全局初始化和导出。
例如:
01import { Hono } from 'hono'02import routes from './routes'0304type AppErrorStatus = 400 | 401 | 403 | 404 | 409 | 422 | 500 | 5040506type Bindings = {07APP_ENV: 'development' | 'test' | 'production'08}0910const app = new Hono<{ Bindings: Bindings }>()1112app.onError((error, c) => {13...14})1516app.notFound((c) => {17...18})1920app.route('/', routes)2122export type AppType = typeof routes2324export default app
这里最重要的一点是:export type AppType = typeof routes 必须发生在所有子路由挂载完成之后。
原因很直接。hc<AppType>() 的嵌套路由类型,是从这个最终 routes 实例推导出来的。如果你在挂载前就导出,web 侧的类型链会丢。
5. 共享 contract 也要按域拆分
如果 route 已经按域拆了,packages/contracts 继续全塞在一个 index.ts 里,就又会形成新的堆积点。
当前更合理的结构是这样:
这里的分层思路也很清楚。
common/放所有域都会复用的公共约定- 各业务域目录只放自己的 request / response contract
index.ts继续聚合导出,对外保持统一入口
例如:
01import type { BizCode } from './biz-code'0203// meta 放请求级别的信息,data/error 放业务结果本身。04export type ApiMeta = {05requestId: string06timestamp: string07}0809export type ApiSuccess<T> = {10ok: true11data: T12meta: ApiMeta13}1415export type ApiError = {16code: BizCode17message: string18details?: unknown19}2021export type ApiFailure = {22ok: false23error: ApiError24meta: ApiMeta25}2627// 所有接口最终都落在 success 或 failure 这两个分支里。28export type ApiResponse<T> = ApiSuccess<T> | ApiFailure2930// 这两个 helper 只是统一拼装响应结构,调用方不用每次手写 ok/data/meta。31export function buildSuccess<T>(data: T, meta: ApiMeta): ApiSuccess<T> {32return {33ok: true,34data,35meta,36}37}3839export function buildFailure(40error: ApiError,41meta: ApiMeta,42): ApiFailure {43return {44ok: false,45error,46meta,47}48}
然后每个域只关心自己的 contract:
01import { z } from 'zod'0203export const CatalogListResponseSchema = z.object({04items: z.array(05z.object({06id: z.string(),07name: z.string(),08category: z.string(),09}),10),11})1213export type CatalogListResponse = z.infer<typeof CatalogListResponseSchema>
这样 route 和 contract 的目录结构基本一一对应,后面找接口会非常容易。
6. web 侧页面和请求文件也要拆开
web 这边同样不能继续把所有请求直接写在页面里。
更合理的结构是两层:
- 页面只负责展示
- 请求文件只负责调接口
首页 apps/web/app/page.tsx 可以退回成一个入口页,只列出验证链接:
01import Link from 'next/link'0203const links = [04'/verify/system/health',05'/verify/system/ping',06'/verify/catalog/list',07'/verify/user/profile',08'/verify/order/detail',09]1011export default function Home() {12return (13<main>14{links.map((href) => (15<Link key={href} href={href}>16{href}17</Link>18))}19</main>20)21}
然后每个页面单独负责验证一个接口:
每个页面只做三件事:
- 调对应的
api.ts - 展示 request / response
- 展示成功态或错误码
页面里不再直接写 hc() 和具体请求细节。
7. 每个接口单独一个 api.ts
请求逻辑应该单独放到 apps/web/src/api/。
建议结构:
其中 client.ts 负责集中创建 Hono client:
01import { getWebServerEnv } from '@/env.server'0203const env = getWebServerEnv()0405export function serverURL(path: string) {06return new URL(path, env.API_BASE_URL).toString()07}0809export function createJsonRequestInit(body?: unknown): RequestInit {10if (body === undefined) {11return {12method: 'GET',13}14}1516return {17method: 'POST',18headers: {19'content-type': 'application/json',20},21body: JSON.stringify(body),22}23}
每个 api.ts 文件只做一件事:调用一个接口。
例如:
01import type {02ApiResponse,03PingRequest,04PingResponse,05} from '@repo/contracts'06import { BizCode } from '@repo/contracts'07import { createJsonRequestInit, serverURL } from '@/api/client'0809export async function postPing(10payload: PingRequest,11): Promise<ApiResponse<PingResponse>> {12try {13const response = await fetch(14serverURL('/rpc/system/ping'),15createJsonRequestInit(payload),16)1718return await response.json()19} catch (error) {20return {21ok: false,22error: {23code: BizCode.SYSTEM_UPSTREAM_TIMEOUT,24message: error instanceof Error ? error.message : 'API request failed',25},26meta: {27requestId: 'unavailable',28timestamp: new Date().toISOString(),29},30}31}32}
8. 页面和接口文件要一一对应
这一点在当前模拟场景里很重要。
页面和 API 文件的对应关系应该固定下来:
verify/system/health/page.tsx↔src/api/system/health.api.tsverify/system/ping/page.tsx↔src/api/system/ping.api.tsverify/catalog/list/page.tsx↔src/api/catalog/list.api.tsverify/user/profile/page.tsx↔src/api/user/profile.api.tsverify/order/detail/page.tsx↔src/api/order/detail.api.ts
这样做的好处是,每个验证页的来源都很清楚。
想查某个页面为什么请求失败,不需要在整个项目里全局搜索,直接看对应的 api.ts 就能定位。
同时,这也满足了这次模拟的核心要求:
- 每个接口请求单独管理
- 每个页面分别验证
- 页面与请求文件职责分离
注意,本文中的内容仅用于接口测试,不用于实际业务开发,在后续的实践开发中,代码会逐渐发生变化