23.04-Vue集成Hono API
要点
- Vue 集成 Hono API 同样使用 Hono RPC 客户端(hc)
- 配合 Vue Query(@tanstack/vue-query)可以实现类似 React Query 的效果
- 在 Composition API 中使用 composable 封装数据获取逻辑
- Vue 3 的
ref和reactive可以自动响应数据变化
内容
1. 安装和配置
1.1 安装依赖
npm install hono @tanstack/vue-query1.2 配置 Vue Query
// src/main.ts
import { createApp } from 'vue'
import { VueQueryPlugin } from '@tanstack/vue-query'
import App from './App.vue'
const app = createApp(App)
// 安装 Vue Query 插件
app.use(VueQueryPlugin)
app.mount('#app')2. 创建 RPC 客户端
// src/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
export const api = hc<AppType>(import.meta.env.VITE_API_URL)
// Vite 使用 VITE_ 前缀的环境变量
// 在 .env 文件中配置:VITE_API_URL=http://localhost:87873. 使用 Composition API 封装
3.1 查询数据
// src/composables/useUser.ts
import { useQuery } from '@tanstack/vue-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()
},
})
}<!-- src/components/UserProfile.vue -->
<script setup lang="ts">
import { useUser } from '@/composables/useUser'
const props = defineProps<{
userId: string
}>()
const { data: user, isLoading, error } = useUser(props.userId)
</script>
<template>
<div v-if="isLoading">加载中...</div>
<div v-else-if="error">加载失败</div>
<div v-else-if="user">
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
</template>3.2 变更数据
// src/composables/useCreateChat.ts
import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { api } from '@/lib/api'
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'] })
},
})
}<!-- src/components/ChatForm.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useCreateChat } from '@/composables/useCreateChat'
const message = ref('')
const createChat = useCreateChat()
function handleSubmit() {
createChat.mutate(message.value)
message.value = ''
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<input v-model="message" placeholder="输入消息" />
<button type="submit" :disabled="createChat.isPending.value">
{{ createChat.isPending.value ? '发送中...' : '发送' }}
</button>
</form>
<div v-if="createChat.data.value">
<p>{{ createChat.data.value.reply }}</p>
</div>
<div v-if="createChat.error.value">
<p>发送失败</p>
</div>
</template>4. Vue 特有的响应式处理
Vue 3 的 ref 和 reactive 可以自动响应数据变化:
// src/composables/useUserList.ts
import { ref, watch } from 'vue'
import { useQuery } from '@tanstack/vue-query'
import { api } from '@/lib/api'
export function useUserList() {
const page = ref(1)
const pageSize = ref(10)
const { data, isLoading, error } = useQuery({
queryKey: ['users', page, pageSize],
queryFn: async () => {
const res = await api.api.users.$get({
query: {
page: page.value.toString(),
pageSize: pageSize.value.toString(),
},
})
if (!res.ok) {
throw new Error('Failed to fetch')
}
return res.json()
},
})
// 监听分页变化,自动重新获取
watch([page, pageSize], () => {
// queryKey 变化时会自动重新获取
})
return {
data,
isLoading,
error,
page,
pageSize,
nextPage: () => page.value++,
prevPage: () => page.value--,
}
}<!-- src/components/UserList.vue -->
<script setup lang="ts">
import { useUserList } from '@/composables/useUserList'
const { data: users, isLoading, page, nextPage, prevPage } = useUserList()
</script>
<template>
<div v-if="isLoading">加载中...</div>
<div v-else>
<ul>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
<div>
<button @click="prevPage" :disabled="page === 1">上一页</button>
<span>第 {{ page }} 页</span>
<button @click="nextPage">下一页</button>
</div>
</div>
</template>5. 全局配置
5.1 默认查询配置
// src/main.ts
import { VueQueryPlugin, QueryClient } from '@tanstack/vue-query'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 分钟
retry: 1,
refetchOnWindowFocus: false, // Vue 项目通常不需要窗口聚焦时刷新
},
},
})
app.use(VueQueryPlugin, {
queryClient,
})5.2 请求拦截器
// src/lib/api.ts
import { hc } from 'hono/client'
import type { AppType } from '@api/src/index'
export const api = hc<AppType>(import.meta.env.VITE_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
},
})6. 错误处理
// src/lib/api-error.ts
export class ApiError extends Error {
constructor(
public status: number,
public message: string,
public data?: any
) {
super(message)
}
}
export 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()
}// src/composables/useUser.ts
import { useQuery } from '@tanstack/vue-query'
import { api } from '@/lib/api'
import { handleResponse } from '@/lib/api-error'
export function useUser(userId: string) {
return useQuery({
queryKey: ['user', userId],
queryFn: async () => {
const res = await api.api.users[':id'].$get({
param: { id: userId },
})
return handleResponse(res)
},
onError: (error) => {
if (error.status === 404) {
console.log('用户不存在')
}
},
})
}7. 实战:完整的 Vue + Hono 集成
// src/composables/index.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/vue-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()
},
})
}
// 创建聊天
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'] })
},
})
}
// 获取聊天历史
export function useChatHistory(userId: string) {
return useQuery({
queryKey: ['chat-history', userId],
queryFn: async () => {
const res = await api.api.chat.history.$get({
query: { userId },
})
if (!res.ok) throw new Error('Failed to fetch')
return res.json()
},
})
}<!-- src/views/Chat.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import { useUser, useCreateChat, useChatHistory } from '@/composables'
const props = defineProps<{
userId: string
}>()
const message = ref('')
const { data: user, isLoading: userLoading } = useUser(props.userId)
const { data: history, isLoading: historyLoading } = useChatHistory(props.userId)
const createChat = useCreateChat()
function handleSubmit() {
createChat.mutate(message.value)
message.value = ''
}
</script>
<template>
<div v-if="userLoading || historyLoading">加载中...</div>
<div v-else>
<h1>和 {{ user?.name }} 聊天</h1>
<!-- 聊天历史 -->
<div class="chat-history">
<div v-for="msg in history" :key="msg.id" class="message">
<strong>{{ msg.role }}:</strong>
<span>{{ msg.content }}</span>
</div>
</div>
<!-- 输入表单 -->
<form @submit.prevent="handleSubmit">
<input v-model="message" placeholder="输入消息" />
<button type="submit" :disabled="createChat.isPending.value">
{{ createChat.isPending.value ? '发送中...' : '发送' }}
</button>
</form>
<!-- 最新回复 -->
<div v-if="createChat.data.value" class="latest-reply">
<p>{{ createChat.data.value.reply }}</p>
</div>
</div>
</template>8. 与 React 的差异
| 特性 | React | Vue |
|---|---|---|
| 数据获取库 | @tanstack/react-query | @tanstack/vue-query |
| 状态管理 | useState, useReducer | ref, reactive |
| 副作用 | useEffect | watch, watchEffect |
| 组件类型 | Function Component | SFC (Single File Component) |
| 响应式 | 手动 setState | 自动响应式 |
核心概念是相同的:
- 都用 RPC 客户端调用 API
- 都用 Query/Mutation 管理数据
- 都用 composable/hook 封装逻辑
9. 小结
Vue 集成 Hono API 的关键点:
- RPC 客户端:和 React 一样使用
hc创建带类型的客户端 - Vue Query:配合
@tanstack/vue-query管理数据获取 - Composition API:使用 composable 封装查询和变更逻辑
- 响应式:Vue 的
ref和reactive自动响应数据变化 - 全局配置:在
main.ts中配置 Vue Query 和请求拦截器
一句话带走:
Vue + Hono 的集成方式和 React 几乎相同,只是用 Vue Query 替代 React Query,用 composable 替代 hook。