Nuxt3封装Http请求客户端及服务端

Nuxt3 封装Http请求客户端及服务端水合解决方案

一、Http请求客户端封装

1.1 核心方案选择

Nuxt3推荐使用内置的$fetchuseFetch进行数据请求,替代传统的Axios。$fetch基于ofetch库实现,提供了更好的TypeScript支持和内置功能。根据场景不同,可选择以下两种封装方式:

  • 服务端渲染(SSR)场景:使用useFetchuseAsyncData,自动处理数据水合
  • 客户端交互场景:使用$fetch,适合用户触发的异步操作

1.2 完整封装实现

创建请求基础配置(utils/request.ts)

import type { UseFetchOptions } from 'nuxt/app'
import { useNuxtApp } from '#app'

// 响应数据类型定义
export interface IResultData<T> {
  code: number
  data: T
  msg: string
}

// 基础URL配置(从环境变量获取)
const getBaseURL = () => {
  const config = useRuntimeConfig()
  return process.server ? config.apiServer : config.public.apiBase
}

/**
 * 通用请求封装
 */
class HttpRequest {
  async request<T = any>(
    url: string,
    method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
    data?: any,
    options?: UseFetchOptions<T>
  ) {
    const nuxtApp = useNuxtApp()
    const token = useCookie('token') // 从cookie获取token
    
    // 基础配置
    const newOptions: UseFetchOptions<T> = {
      baseURL: getBaseURL(),
      method,
      headers: {
        'Content-Type': 'application/json',
        ...(token.value ? { Authorization: `Bearer ${token.value}` } : {})
      },
      ...options
    }

    // 根据请求方法处理数据
    if (method === 'GET' || method === 'DELETE') {
      newOptions.params = data
    } else {
      newOptions.body = data
    }

    // 选择合适的请求方法(服务端/客户端)
    const fetchMethod = process.server ? useFetch : nuxtApp.$fetch

    return new Promise<IResultData<T>>((resolve, reject) => {
      fetchMethod(url, newOptions)
        .then((res: any) => {
          // 处理响应数据
          const result = res._data || res.data?.value
          if (result.code === 200) {
            resolve(result)
          } else {
            reject(new Error(result.msg || '请求失败'))
          }
        })
        .catch((error: any) => {
          // 错误处理
          let errorMessage = '服务端错误'
          if (error.response?._data?.msg) {
            errorMessage = error.response._data.msg
          } else if (error.message) {
            errorMessage = error.message
          }
          reject(new Error(errorMessage))
        })
    })
  }

  // GET请求
  get<T = any>(url: string, params?: any, options?: UseFetchOptions<T>) {
    return this.request<T>(url, 'GET', params, options)
  }

  // POST请求
  post<T = any>(url: string, data?: any, options?: UseFetchOptions<T>) {
    return this.request<T>(url, 'POST', data, options)
  }

  // PUT请求
  put<T = any>(url: string, data?: any, options?: UseFetchOptions<T>) {
    return this.request<T>(url, 'PUT', data, options)
  }

  // DELETE请求
  delete<T = any>(url: string, params?: any, options?: UseFetchOptions<T>) {
    return this.request<T>(url, 'DELETE', params, options)
  }
}

export default new HttpRequest()

配置环境变量(nuxt.config.ts)

export default defineNuxtConfig({
  runtimeConfig: {
    // 服务端专用配置
    apiServer: process.env.NUXT_API_SERVER || 'https://api.example.com',
    // 客户端和服务端共享配置
    public: {
      apiBase: process.env.NUXT_PUBLIC_API_BASE || '/api'
    }
  },
  
  // 开发环境代理配置
  nitro: {
    devProxy: {
      '/api': {
        target: 'https://api.example.com',
        changeOrigin: true,
        // pathRewrite: { '^/api': '' }
      }
    }
  }
})

创建API调用示例(api/user.ts)

import http from '@/utils/request'

export interface UserInfo {
  id: number
  name: string
  avatar: string
}

export const userApi = {
  // 获取用户信息
  getUserInfo: (id: number) => {
    return http.get<IResultData<UserInfo>>(`/user/${id}`, {
      // 配置缓存key,避免水合问题
      key: `user_${id}`
    })
  },
  
  // 更新用户信息
  updateUserInfo: (data: Partial<UserInfo>) => {
    return http.post<IResultData<UserInfo>>('/user/update', data)
  }
}

1.3 请求拦截器实现

通过创建自定义composable实现拦截器功能(composables/useAuthFetch.ts):

import type { UseFetchOptions } from 'nuxt/app'
import { $fetch } from 'ofetch'

export function useAuthFetch<T>(url: string, options: UseFetchOptions<T> = {}) {
  const token = useCookie('token')
  
  // 创建自定义fetch实例
  const customFetch = $fetch.create({
    onRequest({ options }) {
      // 请求拦截器:添加认证头
      if (token.value) {
        options.headers = options.headers || {}
        options.headers.Authorization = `Bearer ${token.value}`
      }
    },
    onResponse({ response }) {
      // 响应拦截器:处理响应数据
      console.log('Response:', response.status, response.url)
    },
    onResponseError({ response }) {
      // 错误处理拦截器
      if (response.status === 401) {
        // 处理未授权错误,例如重定向到登录页
        navigateTo('/login')
      }
    }
  })
  
  // 返回useFetch并使用自定义fetch
  return useFetch(url, {
    ...options,
    $fetch: customFetch
  })
}

二、服务端水合解决方案

2.1 水合机制概述

Nuxt3的服务端水合(Hydration)是指:

  • 服务端渲染(SSR)生成完整HTML页面
  • 客户端加载JavaScript并"激活"页面,使静态HTML变为动态DOM
  • 确保服务端和客户端数据状态一致

常见问题:Hydration Mismatch(水合不匹配),表现为服务端渲染的HTML与客户端生成的DOM结构不一致。

2.2 水合不匹配的解决方案

方案1:使用唯一key确保缓存一致性

// 错误示例:可能导致水合不匹配
const { data } = await useFetch('/api/user')

// 正确示例:提供唯一key
const { data } = await useFetch('/api/user', {
  key: 'user-data' // 固定key确保缓存一致
})

// 动态参数场景:使用参数生成唯一key
const { id } = useRoute().params
const { data } = await useFetch(`/api/user/${id}`, {
  key: `user-${id}` // 动态生成key
})

方案2:处理动态数据和浏览器API

<template>
  <div>
    <!-- 使用client-only包裹客户端特有内容 -->
    <client-only>
      <div class="client-content">
        {{ clientData }}
      </div>
    </client-only>
  </div>
</template>

<script setup>
// 服务端安全的数据获取
const { data: serverData } = await useFetch('/api/data', {
  key: 'server-data'
})

// 客户端特有数据
const clientData = ref(null)

// 在onMounted中使用浏览器API
onMounted(() => {
  clientData.value = window.someClientAPI()
})
</script>

方案3:使用useState共享状态

// composables/useCounter.ts
export const useCounter = () => {
  // 使用useState确保服务端和客户端状态一致
  return useState('counter', () => 0)
}

// 在页面中使用
const counter = useCounter()

// 在服务端修改
if (process.server) {
  counter.value = 10
}

方案4:处理异步数据加载状态

<template>
  <div>
    <!-- 处理加载状态 -->
    <div v-if="pending">加载中...</div>
    
    <!-- 数据加载完成后渲染 -->
    <div v-else-if="data">
      {{ data }}
    </div>
    
    <!-- 错误处理 -->
    <div v-else-if="error">
      错误: {{ error.message }}
    </div>
  </div>
</template>

<script setup>
const { data, pending, error } = await useFetch('/api/data', {
  key: 'page-data',
  // 设置默认值避免null
  default: () => ({})
})
</script>

2.3 高级水合技巧

区分服务端/客户端请求

// composables/useRequest.ts
export const useServerRequest = (url: string, options = {}) => {
  // 仅服务端请求
  return useFetch(url, {
    ...options,
    server: true, // 强制服务端请求
    lazy: false // 阻塞导航直到请求完成
  })
}

export const useClientRequest = (url: string, options = {}) => {
  // 仅客户端请求
  return useFetch(url, {
    ...options,
    server: false, // 强制客户端请求
    lazy: true // 不阻塞导航
  })
}

使用transform处理响应数据

const { data } = await useFetch('/api/users', {
  key: 'users-list',
  // 转换数据格式,避免服务端和客户端处理不一致
  transform: (input) => {
    return input.data.map(user => ({
      id: user.id,
      name: user.name,
      // 统一处理日期格式
      createdAt: new Date(user.createdAt).toLocaleString()
    }))
  }
})

三、完整使用示例

3.1 页面组件中使用

<template>
  <div class="user-profile">
    <h1>{{ user?.name }}的个人资料</h1>
    <div v-if="pending">加载中...</div>
    <div v-else-if="error">获取数据失败: {{ error.message }}</div>
    <div v-else>
      <img :src="user.avatar" alt="头像">
      <p>ID: {{ user.id }}</p>
      <p>注册时间: {{ user.registeredAt }}</p>
    </div>
    <button @click="updateProfile">更新资料</button>
  </div>
</template>

<script setup lang="ts">
import { userApi } from '@/api/user'
import { useCounter } from '@/composables/useCounter'

// 路由参数
const route = useRoute()
const userId = Number(route.params.id)

// 共享状态
const counter = useCounter()

// 服务端获取用户数据
const { data: userData, pending, error } = await useAuthFetch<IResultData<UserInfo>>(`/user/${userId}`, {
  key: `user-${userId}`
})

const user = computed(() => userData?.data || {})

// 客户端交互
const updateProfile = async () => {
  try {
    const result = await userApi.updateUserInfo({
      id: userId,
      name: '新名称'
    })
    // 更新本地数据
    if (result.code === 200) {
      userData.value.data = result.data
    }
  } catch (err) {
    console.error('更新失败:', err)
  }
}
</script>

3.2 处理复杂水合场景

<template>
  <div class="dashboard">
    <h1>数据仪表盘</h1>
    
    <!-- 使用client-only处理图表等客户端组件 -->
    <client-only>
      <ChartComponent :data="chartData" />
    </client-only>
    
    <!-- 服务端渲染的静态内容 -->
    <div class="stats">
      <div class="stat-item">
        <h3>总用户数</h3>
        <p>{{ stats?.totalUsers }}</p>
      </div>
      <div class="stat-item">
        <h3>今日新增</h3>
        <p>{{ stats?.todayNew }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
// 服务端获取统计数据
const { data: stats } = await useFetch('/api/stats', {
  key: 'dashboard-stats',
  // 自定义缓存策略
  getCachedData: (key) => {
    // 开发环境不缓存
    if (process.dev) return null
    return useNuxtApp().payload.data[key]
  }
})

// 客户端处理图表数据
const chartData = ref(null)

onMounted(async () => {
  // 客户端动态获取详细数据
  const result = await $fetch('/api/chart-data', {
    params: { period: '7d' }
  })
  chartData.value = result.data
})
</script>

四、最佳实践总结

4.1 Http请求封装最佳实践

  1. 统一基础配置:通过环境变量和运行时配置管理API地址
  2. 区分请求场景:SSR场景用useFetch,客户端交互用$fetch
  3. 完善错误处理:统一处理网络错误、业务错误和权限错误
  4. 合理使用缓存:通过key参数控制缓存策略,避免重复请求
  5. 类型安全:使用TypeScript接口定义请求和响应类型

4.2 服务端水合最佳实践

  1. 避免服务端/客户端差异代码

    • 不在setup中使用浏览器API
    • 避免使用随机数或时间相关逻辑
  2. 处理动态数据

    • 使用唯一key标识请求
    • 提供默认值避免null
    • 使用transform统一数据处理
  3. 优化水合性能

    • 减少服务端不必要的数据获取
    • 使用lazy模式处理非关键数据
    • 合理拆分组件,使用<client-only>
  4. 调试水合问题

    • 开启Nuxt开发工具调试
    • 使用nuxt dev --debug查看详细日志
    • 检查DOM结构不一致的组件
阅读: 8 | 发布时间: 2025-07-04 22:06:51