23.02-React集成Hono API

要点

  • React 集成 Hono API 的核心是 Hono RPC 客户端(hc)
  • RPC 客户端提供完整的类型推导,后端改动前端立即感知
  • 配合 React Query 可以轻松处理缓存、加载状态、错误处理
  • Server Component 和 Client Component 都可以使用 RPC 客户端

内容

1. Hono RPC 客户端基础

Hono 的 RPC 客户端(hc)是前后端类型安全的关键。它可以从后端 app 类型自动推导出前端调用的类型。

1.1 后端导出 AppType

// apps/api/src/index.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
 
const app = new Hono()
 
// 定义路由(必须链式调用)
app
  .get('/api/users/:id', async (c) => {
    const id = c.req.param('id')
    return c.json({
      id,
      name: 'Alice',
      email: '[email protected]',
    })
  })
  .post(
    '/api/chat',
    zValidator('json', z.object({
      message: z.string(),
    })),
    async (c) => {
      const { message } = c.req.valid('json')
      return c.json({ reply: `你说了:${message}` })
    }
  )
 
// 导出类型
export type AppType = typeof app
 
export default app

注意:路由必须用链式调用(.get().post()),不能用 app.get() 分开写,否则类型推导会失败。

1.2 前端创建 RPC 客户端

// apps/web/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
 
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!)
 
// 现在 api 有完整的类型提示
// api.api.users[':id'].$get()
// api.api.chat.$post()

2. 在 React 组件中使用

2.1 Server Component

Server Component 可以直接 await RPC 调用:

// apps/web/app/users/[id]/page.tsx
import { api } from '@/lib/api'
 
export default async function UserPage({ params }: { params: { id: string } }) {
  const res = await api.api.users[':id'].$get({
    param: { id: params.id },
  })
 
  if (!res.ok) {
    return <div>用户不存在</div>
  }
 
  const user = await res.json()
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

Server Component 的优点:

  • 数据在服务端获取,SEO 友好
  • 不需要 useEffectuseState
  • 自动处理加载和错误状态

2.2 Client Component

Client Component 需要用 useEffect 或 React Query:

// apps/web/components/UserProfile.tsx
'use client'
 
import { useState, useEffect } from 'react'
import { api } from '@/lib/api'
 
export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)
 
  useEffect(() => {
    async function fetchUser() {
      try {
        const res = await api.api.users[':id'].$get({
          param: { id: userId },
        })
 
        if (!res.ok) {
          throw new Error('Failed to fetch')
        }
 
        const data = await res.json()
        setUser(data)
      } catch (err) {
        setError(err)
      } finally {
        setLoading(false)
      }
    }
 
    fetchUser()
  }, [userId])
 
  if (loading) return <div>加载中...</div>
  if (error) return <div>加载失败</div>
  if (!user) return <div>用户不存在</div>
 
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  )
}

这种方式需要手动处理加载、错误状态,代码比较冗长。推荐使用 React Query。

3. 配合 React Query

React Query 是 React 生态中最流行的数据获取库,和 Hono RPC 完美配合。

3.1 安装和配置

npm install @tanstack/react-query
// apps/web/app/providers.tsx
'use client'
 
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 1000 * 60 * 5,  // 5 分钟内认为数据是新鲜的
            retry: 1,
          },
        },
      })
  )
 
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}
// apps/web/app/layout.tsx
import { Providers } from './providers'
 
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

3.2 查询数据

// apps/web/components/UserProfile.tsx
'use client'
 
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
 
export function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await api.api.users[':id'].$get({
        param: { id: userId },
      })
 
      if (!res.ok) {
        throw new Error('Failed to fetch')
      }
 
      return res.json()
    },
  })
 
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>加载失败</div>
 
  return (
    <div>
      <h1>{data.name}</h1>
      <p>{data.email}</p>
    </div>
  )
}

React Query 自动处理:

  • 缓存:相同 queryKey 的请求不会重复发送
  • 重新获取:窗口聚焦、网络恢复时自动刷新
  • 加载状态:isLoadingisFetching
  • 错误处理:error 对象

3.3 变更数据

// apps/web/components/ChatForm.tsx
'use client'
 
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '@/lib/api'
 
export function ChatForm() {
  const queryClient = useQueryClient()
 
  const mutation = useMutation({
    mutationFn: async (message: string) => {
      const res = await api.api.chat.$post({
        json: { message },
      })
 
      if (!res.ok) {
        throw new Error('Failed to send')
      }
 
      return res.json()
    },
    onSuccess: (data) => {
      // 成功后刷新聊天列表
      queryClient.invalidateQueries({ queryKey: ['chat-history'] })
    },
  })
 
  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        const formData = new FormData(e.target)
        const message = formData.get('message') as string
        mutation.mutate(message)
      }}
    >
      <input name="message" />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? '发送中...' : '发送'}
      </button>
      {mutation.data && <p>{mutation.data.reply}</p>}
      {mutation.error && <p>发送失败</p>}
    </form>
  )
}

4. 自定义 Hook 封装

为了复用和简化,可以封装自定义 Hook:

// apps/web/hooks/use-user.ts
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
 
export function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await api.api.users[':id'].$get({
        param: { id: userId },
      })
 
      if (!res.ok) {
        throw new Error('Failed to fetch')
      }
 
      return res.json()
    },
  })
}
// 使用自定义 Hook
import { useUser } from '@/hooks/use-user'
 
function UserProfile({ userId }: { userId: string }) {
  const { data: user, isLoading, error } = useUser(userId)
 
  if (isLoading) return <div>加载中...</div>
  if (error) return <div>加载失败</div>
 
  return <h1>{user.name}</h1>
}

5. 类型推导工具

Hono 提供了类型推导工具,可以从 RPC 方法中提取请求和响应类型:

// apps/web/lib/api-types.ts
import type { InferResponseType, InferRequestType } from 'hono/client'
import { api } from './api'
 
// 响应类型
export type UserResponse = InferResponseType<typeof api.api.users[':id'].$get>
 
// 请求参数类型
export type ChatRequestArgs = InferRequestType<typeof api.api.chat.$post>
 
// 使用
function processUser(user: UserResponse) {
  console.log(user.name)
}
 
function sendChat(args: ChatRequestArgs) {
  api.api.chat.$post(args)
}

6. 错误处理

6.1 统一错误处理

// apps/web/lib/api-client.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
 
class ApiError extends Error {
  constructor(
    public status: number,
    public message: string,
    public data?: any
  ) {
    super(message)
  }
}
 
async function handleResponse<T>(res: Response): Promise<T> {
  if (!res.ok) {
    const error = await res.json().catch(() => null)
    throw new ApiError(
      res.status,
      error?.message || `Request failed with status ${res.status}`,
      error
    )
  }
 
  return res.json()
}
 
export const apiClient = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
  fetch: async (input, init) => {
    const res = await fetch(input, init)
    return res
  },
})
// 使用
try {
  const res = await apiClient.api.users[':id'].$get({ param: { id: '123' } })
  const user = await handleResponse(res)
} catch (err) {
  if (err instanceof ApiError) {
    if (err.status === 404) {
      console.log('用户不存在')
    } else if (err.status === 401) {
      console.log('未登录')
    }
  }
}

6.2 React Query 全局错误处理

// apps/web/app/providers.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        // 401、403、404 不重试
        if (error.status === 401 || error.status === 403 || error.status === 404) {
          return false
        }
        return failureCount < 3
      },
    },
    mutations: {
      retry: false,
    },
  },
})

7. 请求拦截器

添加认证 token、日志等:

// apps/web/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
 
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!, {
  headers: {
    'Content-Type': 'application/json',
  },
  fetch: async (input, init) => {
    // 添加认证 token
    const token = localStorage.getItem('token')
    const headers = new Headers(init?.headers)
 
    if (token) {
      headers.set('Authorization', `Bearer ${token}`)
    }
 
    // 发送请求
    const res = await fetch(input, { ...init, headers })
 
    // 处理 401
    if (res.status === 401) {
      localStorage.removeItem('token')
      window.location.href = '/login'
    }
 
    return res
  },
})

8. 实战:完整的 React + Hono 集成

// apps/web/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
 
export const api = hc<AppType>(process.env.NEXT_PUBLIC_API_URL!)
 
// 自定义 Hook
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
 
export function useUser(userId: string) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const res = await api.api.users[':id'].$get({
        param: { id: userId },
      })
      if (!res.ok) throw new Error('Failed to fetch')
      return res.json()
    },
  })
}
 
export function useCreateChat() {
  const queryClient = useQueryClient()
 
  return useMutation({
    mutationFn: async (message: string) => {
      const res = await api.api.chat.$post({
        json: { message },
      })
      if (!res.ok) throw new Error('Failed to send')
      return res.json()
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['chat-history'] })
    },
  })
}
// apps/web/components/Chat.tsx
'use client'
 
import { useUser, useCreateChat } from '@/lib/api'
 
export function Chat({ userId }: { userId: string }) {
  const { data: user, isLoading } = useUser(userId)
  const createChat = useCreateChat()
 
  if (isLoading) return <div>加载中...</div>
 
  return (
    <div>
      <h1>和 {user.name} 聊天</h1>
      <form
        onSubmit={(e) => {
          e.preventDefault()
          const formData = new FormData(e.target)
          const message = formData.get('message') as string
          createChat.mutate(message)
          e.target.reset()
        }}
      >
        <input name="message" />
        <button type="submit" disabled={createChat.isPending}>
          发送
        </button>
      </form>
      {createChat.data && <p>{createChat.data.reply}</p>}
    </div>
  )
}

9. 小结

React 集成 Hono API 的关键点:

  1. RPC 客户端:使用 hc 创建带类型的客户端
  2. Server Component:直接 await RPC 调用,代码简洁
  3. Client Component:配合 React Query 处理缓存、加载、错误状态
  4. 自定义 Hook:封装常用查询,提高复用性
  5. 类型推导:使用 InferResponseType 等工具提取类型
  6. 错误处理:统一处理 API 错误,避免重复代码

一句话带走:

Hono RPC 客户端 + React Query = 类型安全、缓存自动管理、加载状态处理的完整解决方案。