Hono 前后端分离系列 08:用 Hono RPC 做类型安全客户端
wxk1991 Lv5

Hono 前后端分离系列 08:用 Hono RPC 做类型安全客户端

前后端分离最大的内耗之一,是接口类型不同步。

后端改了:

1
2
3
{
"nickname": "Tom"
}

前端还在用:

1
user.name

结果页面空了,或者线上报错。

传统解决方案是写接口文档、手写 TypeScript 类型、生成 OpenAPI client。

这些都可以。

但如果前后端都用 TypeScript,Hono 还有一个很舒服的方案:Hono RPC。

它不是传统意义上那种复杂 RPC 框架,而是通过 Hono route 类型和 hono/client,让前端获得接口路径、入参和响应类型提示。


一、Hono RPC 解决什么问题

Hono 官方文档说,RPC feature 可以在 server 和 client 之间共享 API specifications。

简单说,它解决这几件事:

1
2
3
4
前端知道有哪些接口
前端知道接口需要什么参数
前端知道接口返回什么类型
改后端时,前端类型能跟着报错

它不要求你放弃 REST。

接口仍然是:

1
2
GET /api/posts
POST /api/posts

只是前端调用方式变成了类型安全的客户端。


二、后端导出 AppType

先写一个 route。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'

const posts = new Hono()

const createPostSchema = z.object({
title: z.string().min(1),
body: z.string().min(1),
})

const route = posts
.get('/', (c) => {
return c.json({
data: {
items: [
{
id: 'p_1',
title: 'Hello Hono',
},
],
},
})
})
.post(
'/',
zValidator('json', createPostSchema),
async (c) => {
const input = c.req.valid('json')

return c.json({
data: {
id: 'p_2',
title: input.title,
},
}, 201)
}
)

export type PostsRoute = typeof route
export default posts

主应用:

1
2
3
4
5
6
7
8
import { Hono } from 'hono'
import posts from './routes/posts'

const app = new Hono()
.route('/api/posts', posts)

export type AppType = typeof app
export default app

关键是:

1
export type AppType = typeof app

前端会使用这个类型。


三、前端创建 hc client

前端安装 Hono:

1
pnpm add hono

创建 client:

1
2
3
4
5
6
import { hc } from 'hono/client'
import type { AppType } from '@my-app/api'

export const client = hc<AppType>(
import.meta.env.VITE_API_BASE_URL
)

调用列表接口:

1
2
3
4
const res = await client.api.posts.$get()
const body = await res.json()

console.log(body.data.items)

调用创建接口:

1
2
3
4
5
6
const res = await client.api.posts.$post({
json: {
title: 'Hono RPC',
body: 'Type-safe API client',
},
})

如果你少传字段,TypeScript 会提示。

如果字段类型错了,TypeScript 也会提示。

这就是它的价值。


四、为什么要配合 validator

Hono RPC 的类型推断,和 route 写法、validator、返回响应都有关系。

如果你只写:

1
const body = await c.req.json()

前端很难知道 body 应该是什么。

配合 zValidator 后,Hono Client 可以推断 input 类型。

这就是为什么我建议:

1
要用 Hono RPC,就认真做 schema

schema 不是额外负担。

它同时服务三件事:

1
2
3
后端运行时校验
前端请求类型
接口契约稳定性

这笔投入很划算。


五、monorepo 里最好用

Hono RPC 最适合 monorepo。

结构类似:

1
2
3
4
5
6
7
apps/
api/
src/index.ts
web/
src/api/client.ts
packages/
shared/

前端可以直接 import 后端导出的类型:

1
import type { AppType } from '../../api/src'

实际项目里更推荐通过 workspace 包导出:

1
2
3
4
5
6
{
"name": "@my-app/api",
"exports": {
".": "./src/index.ts"
}
}

然后前端:

1
import type { AppType } from '@my-app/api'

注意,只 import type。

不要把后端运行时代码打进前端 bundle。


如果要带 cookie,创建 client 时可以配置 credentials

Hono RPC 文档里也提到,可以通过 init 传 fetch 的 RequestInit。

1
2
3
4
5
export const client = hc<AppType>(API_BASE_URL, {
init: {
credentials: 'include',
},
})

如果使用 Bearer Token:

1
2
3
4
5
6
7
8
9
export function createClient(token?: string) {
return hc<AppType>(API_BASE_URL, {
headers: token
? {
Authorization: `Bearer ${token}`,
}
: {},
})
}

也可以在单个请求里传 header:

1
2
3
4
5
6
7
8
await client.api.posts.$get(
{},
{
headers: {
Authorization: `Bearer ${token}`,
},
}
)

七、Hono RPC 不是银弹

Hono RPC 很好用,但不是所有项目都适合。

适合:

1
2
3
4
前后端都用 TypeScript
能共享后端类型
API 主要服务自己的前端
团队愿意用 Hono 的 route 类型约束

不适合:

1
2
3
4
开放 API 给第三方
前端不是 TypeScript
多语言客户端很多
需要正式公开文档和 SDK

如果你要开放接口给外部客户,OpenAPI 仍然更通用。

如果只是自己的 Web 前端调用自己的 Hono API,Hono RPC 非常香。


八、最后的建议

Hono RPC 真正解决的是前后端协作成本。

我的建议是:

1
2
3
4
5
小项目可以先 fetch 封装
接口多了就引入 Hono RPC
RPC 必须配合 validator 才有意义
只共享类型,不共享后端运行时代码
开放平台仍然考虑 OpenAPI

前后端分离不是前端和后端离得越远越好。

好的分离,是职责分开,但契约紧密。

Hono RPC 正好提供了这种紧密契约。


参考资料