23.04-Vue集成Hono API

要点

  • Vue 集成 Hono API 同样使用 Hono RPC 客户端(hc)
  • 配合 Vue Query(@tanstack/vue-query)可以实现类似 React Query 的效果
  • 在 Composition API 中使用 composable 封装数据获取逻辑
  • Vue 3 的 refreactive 可以自动响应数据变化

内容

1. 安装和配置

1.1 安装依赖

npm install hono @tanstack/vue-query

1.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:8787

3. 使用 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 的 refreactive 可以自动响应数据变化:

// 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 的差异

特性ReactVue
数据获取库@tanstack/react-query@tanstack/vue-query
状态管理useState, useReducerref, reactive
副作用useEffectwatch, watchEffect
组件类型Function ComponentSFC (Single File Component)
响应式手动 setState自动响应式

核心概念是相同的:

  • 都用 RPC 客户端调用 API
  • 都用 Query/Mutation 管理数据
  • 都用 composable/hook 封装逻辑

9. 小结

Vue 集成 Hono API 的关键点:

  1. RPC 客户端:和 React 一样使用 hc 创建带类型的客户端
  2. Vue Query:配合 @tanstack/vue-query 管理数据获取
  3. Composition API:使用 composable 封装查询和变更逻辑
  4. 响应式:Vue 的 refreactive 自动响应数据变化
  5. 全局配置:在 main.ts 中配置 Vue Query 和请求拦截器

一句话带走:

Vue + Hono 的集成方式和 React 几乎相同,只是用 Vue Query 替代 React Query,用 composable 替代 hook。