• 如何优雅的封装 Nuxt3 useFetch
  • 发布于 1个月前
  • 104 热度
    0 评论
Nuxt3 数据获取介绍
Nuxt 提供了两个组合函数和一个内置库,用于在浏览器或服务器环境中执行数据获取:useFetch、 useAsyncData 和 $fetch。
简而言之:
useFetch 是在组件设置函数中处理数据获取的最简单方法。
$fetch 可以根据用户交互进行网络请求。
useAsyncData 结合 $fetch,提供了更精细的控制。

useFetch 和 useAsyncData 共享一组常见的选项和模式。


为什么需要使用特定的组合函数

使用像 Nuxt 这样的框架可以在客户端和服务器环境中执行调用和呈现页面时,必须解决一些问题。这就是为什么 Nuxt 提供了组合函数来封装查询,而不是让开发者仅依赖于 $fetch 调用。


网络请求重复
useFetch 和 useAsyncData 组合函数确保一旦在服务器上进行了 API 调用,数据将以有效的方式在负载中传递到客户端。负载是通过 useNuxtApp().payload 访问的 JavaScript 对象。它在客户端上用于避免在浏览器中执行代码时重新获取相同的数据。也就是说,如果你需要使用 SSR 在服务端获取数据时,就需要用到 Nuxt 提供的组合函数。但一般情况下我们是不需要使用 useAsyncData 的,除非是 CMS 或第三方提供自己的查询层时。


useFetch 功能简述

这个可组合函数提供了一个方便的封装,包装了 useAsyncData 和 $fetch。它根据 URL 和 fetch 选项自动生成一个键,根据服务器路由提供请求 URL 的类型提示,并推断 API 响应类型。


我们在实际使用时,大概率需要配置一些默认设置,比如基础路径,自定义请求头等,以及错误处理等拦截器的配置。但可惜的是官方文档对这部分并没有详细的介绍。只是简单的展示了一个使用拦截器的例子:
const { data, pending, error, refresh } = await useFetch('/api/auth/login', {
  onRequest({ request, options }) {
    // 设置请求头
    options.headers = options.headers || {}
    options.headers.authorization = '...'
  },
  onRequestError({ request, options, error }) {
    // 处理请求错误
  },
  onResponse({ request, response, options }) {
    // 处理响应数据
    localStorage.setItem('token', response._data.token)
  },
  onResponseError({ request, response, options }) {
    // 处理响应错误
  }
})
useFetch 的封装
在查阅 issue 和 Nuxt 源码定义后,我自行封装了一套组合函数,这里分享给大家。
// /composables/useHttp.ts

import type { FetchError, FetchResponse, SearchParameters } from 'ofetch'
import { hash } from 'ohash'
import type { AsyncData, UseFetchOptions } from '#app'
import type { KeysOf, PickFrom } from '#app/composables/asyncData'

type UrlType = string | Request | Ref<string | Request> | (() => string | Request)

type HttpOption<T> = UseFetchOptions<ResOptions<T>, T, KeysOf<T>, $TSFixed>
interface ResOptions<T> {
  data: T
  code: number
  success: boolean
  detail?: string
}

function handleError<T>(
  _method: string | undefined,
  _response: FetchResponse<ResOptions<T>> & FetchResponse<ResponseType>,
) {
  // Handle the error
}

function checkRef(obj: Record<string, any>) {
  return Object.keys(obj).some(key => isRef(obj[key]))
}

function fetch<T>(url: UrlType, opts: HttpOption<T>) {
  // Check the `key` option
  const { key, params, watch } = opts
  if (!key && ((params && checkRef(params)) || (watch && checkRef(watch))))
    console.error('\x1B[31m%s\x1B[0m %s', '[useHttp] [error]', 'The `key` option is required when `params` or `watch` has ref properties, please set a unique key for the current request.')

  const options = opts as UseFetchOptions<ResOptions<T>>
  options.lazy = options.lazy ?? true

  const { apiBaseUrl } = useRuntimeConfig().public

  return useFetch<ResOptions<T>>(url, {
    // Request interception
    onRequest({ options }) {
      // Set the base URL
      options.baseURL = apiBaseUrl
      // Set the request headers
      const { $i18n } = useNuxtApp()
      const locale = $i18n.locale.value
      options.headers = new Headers(options.headers)
      options.headers.set('Content-Language', locale)
    },
    // Response interception
    onResponse(_context) {
      // Handle the response
    },
    // Error interception
    onResponseError({ response, options: { method } }) {
      handleError<T>(method, response)
    },
    // Set the cache key
    key: key ?? hash(['api-fetch', url, JSON.stringify(options)]),
    // Merge the options
    ...options,
  }) as AsyncData<PickFrom<T, KeysOf<T>>, FetchError<ResOptions<T>> | null>
}

export const useHttp = {
  get: <T>(url: UrlType, params?: SearchParameters, option?: HttpOption<T>) => {
    return fetch<T>(url, { method: 'get', params, ...option })
  },

  post: <T>(url: UrlType, body?: RequestInit['body'] | Record<string, any>, option?: HttpOption<T>) => {
    return fetch<T>(url, { method: 'post', body, ...option })
  },

  put: <T>(url: UrlType, body?: RequestInit['body'] | Record<string, any>, option?: HttpOption<T>) => {
    return fetch<T>(url, { method: 'put', body, ...option })
  },

  delete: <T>(url: UrlType, body?: RequestInit['body'] | Record<string, any>, option?: HttpOption<T>) => {
    return fetch<T>(url, { method: 'delete', body, ...option })
  },
}
让我们逐步分析这些代码片段:
首先我们定义了一些 type 和 interface 来约束封装的请求方法参数,这里的类型定义是扒的 useFetch 的源码写的(官方这部分使用很多 TypeScript 类型体操,看的让人头疼😬),这里重点提一下 ResOptions,这是我们业务上接口实际返回的数据格式,根据自身情况做调整即可。
接着定义了一个 handleError 的错误处理方法,这里可以做一些通用的错误处理,比如使用全局的消息通知展示错误消息,根据状态码渲染错误页面等。
checkRef 是用于判断对象中是否包含 ref 对象,在参数处理的第一步我们就会用到这个方法。
然后我们封装了一个 fetch 方法,接收两个参数,分别是 url 和 opts,并对类型做了限制,确保其符合 useFetch 方法的参数要求。
然后检查了 key、params 和 watch 几个参数,如果没有手动设置 key,但 params 或 watch 中有 ref 对象时,要进行错误提示。

key 是一个唯一的键,用于确保数据获取可以在请求之间正确去重。如果未提供,将根据使用useAsyncData 的静态代码位置生成。

这是官方文档的介绍,由于请求可能在服务端或客户端去发起,如果 params 和 watch 使用了 ref 对象,并且没有设置唯一的 key,会导致客户端和服务端自动生成的 key 不一致,导致数据在客户端重复获取,这种情况下一定要手动设置唯一的 key。
接着我们将 lazy 选项默认值改为了 true,避免页面切换时的阻塞(但要处理数据 loading 显示效果)。
然后调用官方的 useFetch 方法并返回,在请求拦截器中设置了 baseURL 和 自定义请求头 Content-Language,把当前页面的语言传递过去。请求响应错误拦截器中则调用了之前定义的 handleError 方法处理错误。
然后通过 options 生成默认的 key,最后合并传递的 options。

最终,我们通过封装的 fetch 定义了一个 useHttp 可组合项,包含 get、post、put、delete 方法。


API 的封装
我们以一个新闻列表的 API 举例:
// /api/news.ts

enum API {
  NEWS = '/news',
}

interface NewsDetailModel {
  content: string
  createAt: string
  id: number
  language: string
  summary: string
  title: string
  titleUrl: string
  updateAt: string
  url: string | null
}

export interface NewsListParams {
  _limit?: number
  _page?: number
}

interface PaginationMeta {
  count: number
  limit: number
  page: number
}

interface NewsListResponse {
  items: NewsDetailModel[]
  meta: PaginationMeta
}

export async function getNewsList(params?: NewsListParams, option?: HttpOption<NewsListResponse>) {
  return await useHttp.get<NewsListResponse>(API.NEWS, params, { ...option })
}
API 的使用
<script setup lang="ts">
import { getNewsList } from '~/api/news'

const { data } = await getNewsList()
</script>

<template>
  <div>
    {{ data }}
  </div>
</template>
对于使用了响应式参数的情况,需要手动设置 key:
<script setup lang="ts">
import { hash } from 'ohash'
import { getNewsList } from '~/api/news'
import type { NewsListParams } from '~/api/news'

const page = ref(1)

const { data, pending, error } = await getNewsList({
  _limit: 10,
  _page: page as unknown as NewsListParams['_page'],
}, { key: hash('news_list') })
</script>

<template>
  <div>
    {{ data }}
  </div>
</template>
到这就结束了,这套封装和接口定义,能够让我们不必重复写一些通用的配置,并且能根据返回数据的类型,智能的提示和限制参数,同时如果某些特殊情况下,需要修改一些默认配置,我们也能手动传递参数进行覆盖。除了 SSR 使用的 useFetch,在客户端有时候需要根据用户交互进行网络请求,这时候需要用到官方提供的内置库 $fetch,有空我会把 $fetch 的封装也分享一下,感兴趣的朋友可以多关注下更新。
用户评论