RPC能力概述

要点

  • 前后端之间的类型断裂是一个长期问题:后端改了字段,前端上线才知道
  • 行业对这个问题经历了三个阶段——手写类型、代码生成、运行时类型共享
  • Hono RPC 不是传统意义上的 RPC 框架,它更接近「带类型推导的 HTTP 客户端」
  • 核心思路是把后端路由定义的类型直接导出给前端,跳过代码生成步骤
  • 你得到的:端到端类型安全、补全提示、重构时的编译期保护
  • 你不会得到的:二进制协议、传输层抽象——底层仍然是 HTTP
  • 和 tRPC、OpenAPI codegen、GraphQL 相比,Hono RPC 的路径更轻,但也有自己的适用边界

1. 一个反复出现的问题

前后端协作里,有一类问题每隔几个月就会出现一次:后端改了接口返回的字段名,前端代码编译照常通过,测试跑完也没报错,上线之后页面白屏或者列表为空。排查半小时,发现是 name 被改成了 username

这个问题表面看是「文档没更新」或者「沟通不到位」,但往深一层看,根因在于前后端之间存在一个类型断层:

  1. 后端用 TypeScript(或 Go、Java、Python)定义了接口的输入输出
  2. 前端也需要知道这些输入输出的结构
  3. 这两份「定义」之间没有任何自动同步机制

于是团队引入了各种手段来弥补这个断层。理解这些手段的演进路径,有助于看清 Hono RPC 选了一条什么样的路。

2. 类型共享的三个阶段

2.1 手动维护类型

最早的做法也最常见——前端对着后端代码或者口头约定,自己写一份 interface

// frontend/api/types.ts
// 手动维护,靠人肉同步
 
interface User {
  id: number
  name: string
  email: string
}
 
interface GetUserResponse {
  users: User[]
  total: number
}

这种方式的问题不用多说。类型定义和实际实现之间没有约束关系,全靠自觉。后端改了字段,前端可能一周后才发现。

2.2 从规范生成类型

为了解决手动同步的问题,行业逐渐形成了「先写规范,再自动生成」的流程。OpenAPI(Swagger)是最典型的代表:

  1. 后端写一份 OpenAPI 规范(YAML 或 JSON),描述每个接口的路径、参数、响应结构
  2. 用工具(openapi-typescript、orval 等)从规范生成前端的 TypeScript 类型定义
  3. 前端引入生成的类型,或者直接使用生成的客户端函数

这条路比手写类型可靠得多。规范本身就是文档,生成工具能保证类型和规范一致。但它引入了一个新问题——规范与实现之间的同步

  • 后端改了路由实现,忘了更新 OpenAPI 规范
  • 规范更新了,前端忘了重新运行生成命令
  • 生成工具和实际框架之间的类型表达有细微差异

流程上多了一层间接性。每一步单独看都没错,但组合在一起就多了一个可能出错的环节。

2.3 直接从实现提取类型

Hono RPC 走的是第三条路——不走规范、不做代码生成,直接从后端的路由实现中提取类型信息,传给前端使用。

后端路由定义 → typeof 提取类型 → 前端 import type → 客户端自动推导

后端写路由的代码本身就是「类型规范」。不需要额外维护一份 YAML,不需要跑生成脚本。后端改了字段名,前端下次编译时就会报错——因为类型信息是从后端代码里直接提取的,中间没有人工环节。

这条路径成立的前提是 TypeScript 的类型系统足够强大,能够从路由注册代码中推导出完整的接口信息。Hono 的链式路由写法正是为了满足这个前提而设计的。

3. Hono RPC 是什么,不是什么

在展开具体用法之前,先划定 Hono RPC 的边界。这个名字里有「RPC」,但它和传统 RPC 框架的距离比想象中更远。

3.1 RPC 的本意

RPC(Remote Procedure Call)的核心想法是:让调用远程接口看起来像调用本地函数。客户端写 getUser(42),框架负责把它翻译成 HTTP 请求(或 gRPC 调用),拿到结果后返回给调用方。调用方不需要关心 URL 怎么拼、参数放 body 还是 query、响应怎么解析。

传统 RPC 框架通常具备这些特征:

  1. 一套独立的接口定义语言或协议(protobuf、IDL)
  2. 自己的传输层(可以是 HTTP/2、TCP、Unix socket)
  3. 序列化格式(binary、MessagePack、protobuf)
  4. 代码生成工具,从接口定义生成客户端和服务端桩代码

3.2 Hono RPC 的选择

Hono RPC 拿掉了上面大部分东西。它没有独立的协议定义,没有自己的传输层,没有自定义序列化格式。它做的事情可以归纳为三条:

  1. 类型共享:把后端路由定义中的类型信息提取出来,通过 TypeScript 的类型系统传给前端
  2. 调用封装hc 客户端把 fetch 调用包装成对象属性访问,URL 映射为属性路径,HTTP 方法映射为 $get()$post() 等方法
  3. 类型推导:前端调用时,请求参数和响应类型都从后端路由定义自动推导,不需要手写 interface

底层仍然是普通的 HTTP 请求和 JSON 序列化。你在浏览器 DevTools 的 Network 面板里看到的,和手写 fetch 没有区别。

所以更准确的说法是:Hono RPC 是一套基于 TypeScript 类型系统的 HTTP 客户端类型推导方案。它解决的核心问题是类型同步,不是传输协议。

4. 核心思路:路由定义即类型规范

Hono RPC 的思路可以浓缩成一句话:后端的路由定义代码,同时充当了接口类型规范。

// server/routes/users.ts
// 这份代码同时做了两件事:注册路由 + 定义类型
 
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
 
const createUserSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
})
 
const users = new Hono()
  .get('/', async (c) => {
    // 响应类型从这里推导
    return c.json({ users: [{ id: 1, name: 'Alice', email: '[email protected]' }] })
  })
  .post('/', zValidator('json', createUserSchema), async (c) => {
    const data = c.req.valid('json')
    // 请求体类型从 zod schema 推导
    // 响应类型从这里推导
    return c.json({ user: { id: 2, ...data } }, 201)
  })
 
export default users

这份代码里没有任何「为了 RPC 而额外添加」的东西。Hono 实例的路由注册、zValidator 的参数校验、c.json() 的响应构造,都是正常的后端开发。类型信息是这些代码天然携带的副产品。

前端要做的事只有一件——引入这份路由的类型:

// frontend/api/client.ts
import { hc } from 'hono/client'
import type { AppType } from '../../server/app'
 
// AppType 包含了所有路由的完整类型信息
const client = hc<AppType>('http://localhost:8787')
 
// client.users.$get() 的参数和返回值类型自动推导
const res = await client.users.$get()
const data = await res.json()
// data 的类型:{ users: { id: number; name: string; email: string }[] }

从后端写路由到前端拿到类型推导,中间不需要跑任何命令,不需要维护任何配置文件。类型信息通过 TypeScript 的 import type 直接流通。

5. 你得到什么,得不到什么

在决定是否采用 Hono RPC 之前,有必要清楚地列出它的能力边界。

5.1 你能得到的

端到端类型安全

从后端的 c.json() 到前端的 res.json(),类型信息一路贯通。后端改了返回字段,前端 TypeScript 编译直接报错。这个保护覆盖请求参数、查询参数、路径参数、请求体和响应体。

IDE 补全

在 IDE 里输入 client.users. 就能看到所有可用的方法($get$post)。输入 .$post(&#123; json: &#125;) 之后,json 对象的字段会自动提示。开发体验和调用本地函数基本一致。

重构安全

后端重命名一个返回字段,整个前端仓库里所有用到这个字段的地方都会标红。不需要搜文档,不需要跑回归测试——TypeScript 编译器帮你完成了全链路的影响面排查。

零额外流程

不需要写 OpenAPI 规范,不需要跑代码生成脚本,不需要维护生成产物的版本。后端改了路由,前端 import type 自动拿到最新类型(前提是 monorepo 或共享类型包)。

5.2 你不会得到的

二进制协议

底层是 HTTP + JSON。如果你的场景需要高效的二进制传输(比如大规模数据流、实时通信),Hono RPC 不会帮你抽象掉这些。该用 WebSocket 就用 WebSocket,该用 gRPC streaming 就用 gRPC streaming。

传输层抽象

hc 不隐藏 HTTP 的细节。你拿到的仍然是标准的 Response 对象,需要自己 await res.json()。HTTP 状态码、响应头、错误处理都按 HTTP 的方式来。它把 URL 映射成了属性路径,但并没有假装自己不是在发 HTTP 请求。

跨语言支持

类型共享依赖 TypeScript 的类型系统。后端和前端必须都是 TypeScript(或至少前端是 TypeScript 且能引用后端的类型定义)。如果后端是 Go 或 Java,前端是 React,Hono RPC 的路径走不通——这种场景更适合 OpenAPI 规范。

运行时的类型保护

类型信息只在 TypeScript 编译期生效。运行时不做任何校验。如果后端部署了新版本但前端没有重新构建,前端调用的仍然是旧的类型约定——TypeScript 不会在运行时拦截这种不匹配。

6. 和其他方案的对比

Hono RPC 不是唯一的类型共享方案。把它和几种常见路径放在一起看,更容易理解它的位置。

6.1 与 tRPC 对比

tRPC 和 Hono RPC 的目标高度重合——都是在 TypeScript 全栈场景下做类型共享,都不需要代码生成。两者的关键差异在于:

  1. 运行环境:tRPC 设计时假设前后端可以共享 Node.js 进程或通过特定适配器通信。Hono RPC 是纯 HTTP 客户端,前端可以是任何能跑 fetch 的环境
  2. 协议:tRPC 有自己的请求格式和批处理机制。Hono RPC 底层是标准 HTTP + JSON,对调试和中间件更友好
  3. 框架绑定:tRPC 和后端框架紧耦合(主要搭配 Express / Fastify / Next.js)。Hono RPC 直接内建在 Hono 里,不需要额外依赖
  4. 生态成熟度:tRPC 的社区更大,配套工具(React Query 集成、Next.js 适配器)更完善。Hono RPC 的生态还在成长中

如果你的项目已经在用 tRPC 并且没有遇到痛点,切换到 Hono RPC 的收益有限。如果后端框架就是 Hono,那直接用内建的 RPC 能力通常比引入 tRPC 更轻量。

6.2 与 OpenAPI 代码生成对比

OpenAPI 规范的适用场景更广。它不依赖特定语言或框架,后端可以是任何语言,只要遵循 OpenAPI 规范就能生成前端类型。

但 OpenAPI 流程多了一层间接性——规范和实现之间需要人工保持同步。Hono RPC 直接消除了规范这一层,让类型信息从实现本身流出。

两者的选择可以用一个简单的判断来辅助:

  • 后端是 TypeScript + Hono,前端也是 TypeScript → Hono RPC 更直接
  • 后端是其他语言,或需要对外提供 API 文档 → OpenAPI 规范更合适
  • 两者不互斥:Hono 也支持从路由定义生成 OpenAPI 文档,后面第 8 篇会展开

6.3 与 GraphQL 对比

GraphQL 解决的是另一个维度的问题——客户端按需查询。它让前端决定要哪些字段,后端按请求组装数据。这减少了「版本化接口」的需求,但也引入了 schema 维护、查询复杂度控制、N+1 问题等新复杂度。

Hono RPC 不介入数据查询的模式。后端定义什么样的接口,前端就调什么样的接口。如果接口粒度过粗或过细,那是 API 设计的问题,不是 RPC 层能解决的。

两者也不是互斥的。实际项目中可以 REST 接口走 Hono RPC,需要灵活查询的场景引入 GraphQL。

7. 什么时候值得用

不是所有项目都需要 Hono RPC。几个可以参考的判断条件:

前后端共享类型仓库

Hono RPC 的类型共享前提是前端能 import type 后端的路由类型。monorepo 是最自然的场景——前后端代码在同一个仓库,通过 TypeScript path alias 或包引用共享类型。如果前后端分属不同仓库,需要额外发布类型包,流程上会多一步。

接口数量多、迭代快

接口只有三五个的时候,手写类型的维护成本还能接受。接口到了二三十个,每次后端改字段前端都要人肉排查,成本就开始明显上升。Hono RPC 在这个规模段的收益比较突出。

团队规模

一个人全栈开发时,前后端都是自己的代码,类型不同步的问题不太严重。三人以上的团队协作时,前后端由不同的人负责,类型同步的沟通成本才真正显现。

TypeScript 全栈

这条是硬性前提。后端或前端有一方不是 TypeScript,Hono RPC 的类型共享路径就走不通。

如果项目同时满足上面几个条件,Hono RPC 的引入成本很低——它不需要额外安装框架、不需要配置代码生成、不需要写协议定义。后端路由改成链式写法,导出 AppType,前端用 hc 接入,就能开始获得类型保护。

8. 心智模型:把路由当作有类型的函数

使用 Hono RPC 时,一个比较贴近实际的心智模型是:后端每条路由都是一个有类型的函数,前端通过 hc 调用这个函数。

后端:
  GET  /users       → (query?) => { users: User[] }
  POST /users       => (json: CreateUserInput) => { user: User }
  GET  /users/:id   → (param: { id: string }) => { user: User }

前端:
  client.users.$get({ query })          → 返回类型自动推导
  client.users.$post({ json })          → 返回类型自动推导
  client.users[':id'].$get({ param })   → 返回类型自动推导

这个对应关系不是运行时映射——后端并没有真的把路由注册成函数。它是在 TypeScript 类型层面的映射:AppType 里记录了每条路由的输入输出类型,hc 在类型层面把这些信息转换成对象属性方法的签名。运行时走的仍然是 HTTP。

理解这一点很重要。它意味着你在前端写的 client.users.$get() 在运行时就是一个 fetch 调用,类型安全完全由 TypeScript 编译器保证。如果你把编译产物拿到 JavaScript 环境里用,类型保护就消失了。

9. 这个系列会覆盖什么

这个系列会沿着 Hono RPC 的能力链路,从类型导出到客户端调用,再到实际项目中的组织和排错,逐篇展开。

  1. 本篇(01):RPC 能力概述——问题背景、方案定位、适用边界
  2. 02:AppType 导出机制——链式路由的类型推导原理,typeof route 导出了什么
  3. 03:Hono Client 使用方式——hc 的调用语法、参数传递、与 React Query 配合
  4. 04:服务端类型推导——Zod validator 怎样把运行时校验和编译期类型合到一条链路
  5. 05:客户端类型推导——hc 内部怎样从 AppType 生成调用签名
  6. 06:Monorepo 实践——类型共享在 monorepo 中的目录结构和构建配置
  7. 07:API 类型共享规范——团队协作中如何约定路由命名、类型导出、版本管理
  8. 08:RPC 与 OpenAPI 对比——两种路径的适用场景和组合方式
  9. 09:RPC 在 AI 项目中的应用——流式响应、SSE 与 RPC 类型共享的配合
  10. 10:常见类型错误处理——链式写法遗漏、泛型丢失、中间件类型断裂等高频问题的排查

每篇都会从代码出发,尽量把类型推导的过程拆开看,不只是告诉你可以这样做,也说明为什么这样做能跑通。

延伸阅读

总结

Hono RPC 解决的核心问题是前后端之间的类型断层。它没有引入新协议或代码生成流程,而是利用 TypeScript 的类型系统,让后端路由定义直接充当接口类型规范,前端通过 import type 获取完整的类型信息。

这种方式的收益是直接的:端到端类型安全、IDE 补全、重构时的编译期保护,且不需要额外的工具链。代价是前后端必须共享 TypeScript 类型仓库,底层仍然是 HTTP + JSON,不提供跨语言支持或传输层抽象。

是否采用取决于项目条件——TypeScript 全栈、接口数量和迭代频率达到一定规模、团队有类型同步的痛点。满足这些条件时,Hono RPC 的引入成本很低,收益比较明确。

下一篇进入 AppType 导出机制——看链式路由写法是怎样让 TypeScript 把路由信息编码进类型里的。