【Nuxt3系列九】数据获取(重要)

2,362 阅读10分钟

Nuxt的数据获取是比较重要的一环。因为会影响页面的水合过程。好的设计,会使得页面加载变快。

对比于传统的vue项目多使用axios进行二次封装不同。Nuxt天然提供了几种数据获取的方式useFetch,useAsyncData$fetch

简单点说:

  • useFetch: 是获取数据最简单的方法,它可以在客户端和服务端执行,并提供缓存功能
  • $fetch: 地位相当于axiosuseFetch是对$fetch的封装
  • useAsyncData: 它相当于一个壳,可以接受一个异步参数,使得这个异步函数也能像useFetch一样使用。但是在Nuxt当中,这个异步函数一般是$fetch

在此之前,有必要了解为什么会有这些函数

为什么需要使用特定的composables来获取数据?

为什么会存在useFetchuseAsyncData这两个composables?

答案是避免重复请求数据

什么叫避免请求重复数据?首先我们需要了解Nuxt框架的本身,它是一个支持SSR渲染的框架。

服务端渲染的好处是,服务器先提前获取数据渲染出来完整的单个页面的html。这样客户就能立刻查看到数据,速度会快很多。

但是,依赖于Vue框架的其它特性,例如单页面的切换、很多的动态效果仍然需要js动态执行

于是当客户端收到完整的html之后,还会在后台,下载执行js。然后重新在客户端渲染,使得原本的静态html也拥有了动态的能力!

客户端重新渲染,并且把静态HTML恢复成动态HTML的过程,就叫做水合(Hydration)

看见没有,上面这段话,说到了两次渲染,一次是服务端,一次是客户端。这就意味着,页面需要获取两次数据。但是这两次数据,实际上间隔时间这么短,并且请求参数都是一样的,完全是重复的,没有必要的。请求两次也会对后端造成压力。

那么useFetchuseAsyncData的作用就体现出来了。在服务器渲染的时候,useFetch会请求后端,获取数据,并且把数据缓存起来,在页面水合的时候,把缓存的数据传递到客户端,代替掉useFetch的一次请求!

网络请求的复制

上面我们说了,useFetchuseAsyncData一旦在服务端被调用,数据就会以载荷(payload)的形式转发到客户端

payload是一个JavaScript对象,可以通过useNuxtApp().playload进行访问。它用于客户端中,避免在水合期间重复请求相同的数据

Suspense

Nuxt 在幕后使用 Vue 的 <Suspense> 组件来防止在所有异步数据可用之前进行导航。useFetchuseAsyncData 可以帮助您利用这一特性,并根据每次调用的具体需求选择最适合的方式。

您可以在应用中添加 <NuxtLoadingIndicator> 来在页面导航之间显示进度条。

useFetch

这是最简单的最直接的数据获取方式

<script setup lang="ts">
const { data: count } = await useFetch('/api/count')
</script>

<template>
  <p>Page visits: {{ count }}</p>
</template>

useFetch('/api/count')

相当于

useAsyncData('key',()=>$fetch('/api/count'))

$fetch

$fetch 实际上是nuxt整合了 ofetch这个库,重命名为$fetch并全局自动导入。useFetch的底层就是基于$fetch

ofetch相当于axios,只不过是不同的实现方式

<script setup lang="ts">
async function addTodo() {
  const todo = await $fetch('/api/todos', {
    method: 'POST',
    body: {
      // My todo data
    }
  })
}
</script>

useAsyncData

useAsyncData的作用是封装异步逻辑并且在异步逻辑结束后返回结果。

useFetch(url)等价于useAsyncData(url,()=>$fetch(url))。useFetch只是个语法糖

有些情况,useFetch并不合适使用。例如需要在发送前需要鉴权、或者第三方提供了查询层的时候。

这种情况下我们就需要用到useAsyncData

下面这种写法,是等价的

<script setup lang="ts">
const { data, error } = await useAsyncData('users', () => myGetFunction('users'))

// This is also possible:
const { data, error } = await useAsyncData(() => myGetFunction('users'))
</script>

useAsyncData的第一个参数是一个唯一的键(key),用于缓存第二个参数(查询函数)的响应结果。第一个参数也可以被忽略,通过直接传递查询函数,键将会自动生成。

由于自动生成的键只考虑useAsyncData被调用的文件和行位置。因此建议自己管理这个键,避免自动生成的键导致的不合预期的行为。

同时,设置一个键对于正在使用useNuxtData的组件之间共享相同的数据或刷新特定数据是非常有用的。

自己管理键的例子

<script setup lang="ts">
const { id } = useRoute().params

const { data, error } = await useAsyncData(`user:${id}`, () => {
  return myGetFunction('users', { id })
})
</script>

useAsyncData还可以高效封装并等待多个$fetch的请求完成,然后处理这些结果

<script setup lang="ts">
const { data: discounts, status } = await useAsyncData('cart-discount', async () => {
  const [coupons, offers] = await Promise.all([
    $fetch('/cart/coupons'),
    $fetch('/cart/offers')
  ])

  return { coupons, offers }
})
// discounts.value.coupons
// discounts.value.offers
</script>

返回值

useFetchuseAsyncData都有相同的返回值

  • data: 返回结果
  • refresh/execute: 执行函数,不同的名称
  • clear: 清理方法,会将data设置为undefinederror设置为nullstatus设置为idle,并且把所有当前正在准备的请求,全部取消
  • error: 当执行错误的时候,返回的一个错误的对象
  • status: 状态"idle""pending""success""error"

dataerror 和 status 诗vue的ref对象,在<script setup>中使用需要带上 .value

设置

延迟Lazy

默认情况下,Nuxt会利用Vue的Suspense,在页面导航前等待所有的异步函数完成。如果你设置了Lazy,则可以忽略这个特性。但是因此需要手动使用status处理值是否加载完毕

<script setup lang="ts">
const { status, data: posts } = useFetch('/api/posts', {
  lazy: true
})
</script>

<template>
  <!-- you will need to handle a loading state -->
  <div v-if="status === 'pending'">
    Loading ...
  </div>
  <div v-else>
    <div v-for="post in posts">
      <!-- do something -->
    </div>
  </div>
</template>

你可以在参数设置,但是Nuxt也提供了另外一种方式,useLazyFetchuseLazyAsyncData可以实现同样的效果

<script setup lang="ts">
const { status, data: posts } = useLazyFetch('/api/posts')
</script>

仅客户端运行

默认情况下,数据获取的组合式函数会在客户端和服务器环境中执行其异步函数。如果需要仅在客户端中执行,则将server设置为false。在初始化的时候,数据不会在水合完成之前获取,因此你需要处理待定状态。但是再后续的客户端导航中,页面加载前会等待数据获取完成

结合lazy选项使用,这对于那些在首次渲染时不需要的数据非常有用(例如对SEO没有帮助的数据)

/* This call is performed before hydration */
const articles = await useFetch('/api/article')

/* This call will only be performed on the client */
const { status, data: comments } = useFetch('/api/comments', {
  lazy: true,
  server: false
})

重要提醒

useFetch组合式函数(实际上useAsyncData也一样)旨在setup方法中调用,或者直接在生命周期钩子函数的顶层调用,否则你应该使用$fetch方法

官网的这句话,在一个不起眼的角落里。但是实际上,它是一个很重要的特性。

什么叫做在setup方法中调用?下面举个例子

<script setup>
//这一行是正确的
const {data} = await useAsyncData(url,()=>$fetch(url)) // 实际等价于const {data} = useFetch(url)


const data2 = ref()
// 错误写法-1
const handleClick = async () => {
  // 这里不是setup的顶层,所以会提示一个warning
  const {data} = await useAsyncData(url,()=>$fetch(url))
  data2.value = data.value
}

// 错误写法-2
// 这里试图先声明,再在函数内调用执行方法
const {data: tempData,execute} = await useAsyncData(url,()=>$fetch(url,{immediate:false}))
const handleClick = async () => {
  // 同样的,这里不是setup的顶层,虽然声明在顶层,但是实际上调用是非顶层
  execute()
  data2.value = tempData.value
}

</script>

<template>
    <div>{{data}}</div>
    
    <button @click="handleClick">click me</button>
    <div>{{data2}}
</template>

所以,如果是像@click方法内需要获取数据,请把$fetch当作axios一样使用

最小载荷 Minimize payload size

文章上面我们提到过,服务器中执行fetch之后,会把数据放到payload中,传递给客户端进行水合,避免重复获取数据。

这个过程,也是耗用网络资源的,如果我们需要更快的访问,我们可以把需要的部门传递就可以了

使用pick选项

<script setup lang="ts">
/* only pick the fields used in your template */
const { data: mountain } = await useFetch('/api/mountains/everest', {
  pick: ['title', 'description']
})
</script>

<template>
  <h1>{{ mountain.title }}</h1>
  <p>{{ mountain.description }}</p>
</template>

如果需要对多个对象进行更多的控制或者映射,可以使用transform函数改变查询的结果

const { data: mountains } = await useFetch('/api/mountains', {
  transform: (mountains) => {
    return mountains.map(mountain => ({ title: mountain.title, description: mountain.description }))
  }
})

picktransform都不会改变服务端初次从后端获取的字段,但是可以防止多余的字段传递到客户端

缓存和重新获取

keys

useFetchuseAsyncData都使用了键(keys)来防止重复获取相同的数据

  • useFetch使用提供的URL作为键。另外,也可以手动在option中提供一个键
  • useAsyncData的第一个参数是字符串的话,那么这个字符串就是键。忽略这个第一个参数,直接传进异步函数,那么Nuxt将为这个请求生成一个由文件名(你调用所在的文件名)和行号(在哪里调用的行号)组成的key

刷新和执行

如果你想手动刷新数据,你可以执行execute或者是refresh函数,他们的作用完全一样。

<script setup lang="ts">
const { data, error, execute, refresh } = await useFetch('/api/users')
</script>

<template>
  <div>
    <p>{{ data }}</p>
    <button @click="() => refresh()">Refresh data</button>
  </div>
</template>

清理

如果你想清理已经获取到的数据,你不需要往clearNuxtData函数里面传递特定的键,你只需要执行clear函数,即可自动清理

<script setup lang="ts">
const { data, clear } = await useFetch('/api/users')

const route = useRoute()
watch(() => route.path, (path) => {
  if (path === '/') clear()
})
</script>

Watch 监控

要在应用程序中的其他响应式值发生变化时重新运行获取函数,请使用watch选项。你可以将其用于一个或多个可观看元素。

要是想要当特定的响应式值发生变化的时候,重新运行获取函数,可以使用watch属性,支持多个对象

<script setup lang="ts">
const id = ref(1)

const { data, error, refresh } = await useFetch('/api/users', {
  /* Changing the id will trigger a refetch */
  watch: [id]
})
</script>

特别注意

watch的对象更改,是不会影响到获取数据的URL的。URL一旦被声明构造了,就永远不会变化,就像下面这个例子一样
<script setup lang="ts">
const id = ref(1)

const { data, error, refresh } = await useFetch(`/api/users/${id.value}`, {
  watch: [id]
})
</script>

如果你希望能够更改这个url,你可以使用计算的URL(Computed URL)

Computed URL

如果url传递一个id参数,你可以使用query选项

<script setup lang="ts">
const id = ref(null)

const { data, status } = useLazyFetch('/api/user', {
  query: {
    user_id: id
  }
})
//这里最终会请求'/api/user?user_id=id'
</script>

但是我们还是没有解决上面那种restful风格传递参数的例子

这个时候我们可以传入一个计算URL的回调函数,结合immediate: false,我们可以等到响应值变化完之后再执行函数

<script setup lang="ts">
const id = ref(null)

const { data, status } = useLazyFetch(() => `/api/users/${id.value}`, {
  immediate: false
})

const pending = computed(() => status.value === 'pending');
</script>

<template>
  <div>
    <!-- disable the input while fetching -->
    <input v-model="id" type="number" :disabled="pending"/>

    <div v-if="status === 'idle'">
      Type an user ID
    </div>

    <div v-else-if="pending">
      Loading ...
    </div>

    <div v-else>
      {{ data }}
    </div>
  </div>
</template>

手动执行 not immediate

useFetch被调用的那一刻就开始获取数据了。如果需要等待用户输入再获取数据。需要设置immediate: false

这样的话,你需要通过status管理获取数据的生命周期(没有获取完成之前,data是null的,这会导致页面渲染报错。所以你要通过status去控制是否渲染)和execute函数

<script setup lang="ts">
const { data, error, execute, status } = await useLazyFetch('/api/comments', {
  immediate: false
})
</script>

<template>
  <div v-if="status === 'idle'">
    <button @click="execute">Get data</button>
  </div>

  <div v-else-if="status === 'pending'">
    Loading comments...
  </div>

  <div v-else>
    {{ data }}
  </div>
</template>

status的值可以为

  • idle 尚未开始获取数据
  • pending 开始获取数据,但是还没有结束
  • error 获取失败
  • success 获取成功

传递Headers和Cookies

当在浏览器中调用$fetch,cookie和headers都会直接发送到api中。但是当在服务端渲染的时候,$fetch是不包括浏览器中的cookies的,也不会传递fetch响应中的cookies的。

这个时候,需要我们用点特殊技巧实现了

传递客户端header到api

我们可以使用useRequestHeaders从服务器去访问和代理cookies到api

<script setup lang="ts">
const headers = useRequestHeaders(['cookie'])

const { data } = await useFetch('/api/me', { headers })
</script>

将headers代理给外部api是一个危险操作,并不是所有的header都可以安全绕过的。下面是不可以被代理的header

  • hostaccept
  • content-lengthcontent-md5content-type
  • x-forwarded-hostx-forwarded-portx-forwarded-proto
  • cf-connecting-ipcf-ray

SSR渲染时,从服务端的api调用传递cookies到客户端

如果你想将 cookies 从内部请求反向传递/代理回客户端,你需要自行处理这一点。

import { appendResponseHeader } from 'h3'
import type { H3Event } from 'h3'

export const fetchWithCookie = async (event: H3Event, url: string) => {
  /* 从后端获取response */
  const res = await $fetch.raw(url)
  /* 从response获取cookies */
  const cookies = res.headers.getSetCookie()
  /* 将每个cookie附加到传入的请求 */
  for (const cookie of cookies) {
    appendResponseHeader(event, 'set-cookie', cookie)
  }
  /* 最终返回数据 */
  return res._data
}
<script setup lang="ts">
// This composable will automatically pass cookies to the client
const event = useRequestEvent()

const { data: result } = await useAsyncData(() => fetchWithCookie(event!, '/api/with-cookie'))

onMounted(() => console.log(document.cookie))
</script>

对选项式api(Options API)的支持

回顾一下vue3两种风格的写法

  • 组合式 Composition API
  • 选项式 Options API

组合式就是

<script setup>
 // code. ...
</script>

或者是

<script>
export default { setup() {
    const count = ref(0)
    return {count}
}}
</script>

而选项式就像是vue2比较传统的方式,也就是返回一个默认的、特定格式的对象

<script>
export default{
  data:{},
  methods:{},
  mounted(){
  }
 }
</script>

Nuxt3中,推荐使用组合式api,并且是配合<script setup>使用

但是我们同样可以使用选项式API来定义异步函数

<script lang="ts">

export default defineNuxtComponent({
  async asyncData() {
    // 模拟异步数据获取
    const data = await fetch('https://api.example.com/data')
    const json = await data.json()
    
    return {
      data: json
    }
  }
})
</script>

<template>
  <div>
    <h1>{{ data.title }}</h1>
    <p>{{ data.description }}</p>
  </div>
</template>

从服务器序列化数据到客户端

载荷(playload)是使用了devalue序列化的。也可以使用自定义的序列化器/反序列化器。详情请看useNextApp的文档

从API路由序列化数据

从服务器目录获取数据时,response使用 JSON.stringify 进行序列化。然而,由于序列化仅限于 JavaScript 的原始类型,Nuxt 尽力将 $fetchuseFetch 的返回类型转换以匹配实际值。

server/api/foo.ts

export default defineEventHandler(() => {
  return new Date()
})
<script setup lang="ts">
// Type of `data` is inferred as string even though we returned a Date object
const { data } = await useFetch('/api/foo')
</script>

自定义序列化函数

要自定义序列化,可以在返回的对象上定义toJSON函数。如果定义了toJSON,Nuxt会遵守这个函数的返回类型,不会尝试转换这些类型

server/api/bar.ts

export default defineEventHandler(() => {
  const data = {
    createdAt: new Date(),

    toJSON() {
      return {
        createdAt: {
          year: this.createdAt.getFullYear(),
          month: this.createdAt.getMonth(),
          day: this.createdAt.getDate(),
        },
      }
    },
  }
  return data
})

<script setup lang="ts">
// Type of `data` is inferred as
// {
//   createdAt: {
//     year: number
//     month: number
//     day: number
//   }
// }
const { data } = await useFetch('/api/bar')
</script>

使用替代的序列化器

Nuxt目前不支持JSON.stringify的替代序列化器。但是,您可以将有效载荷作为普通字符串返回,并利用toJSON方法来维护类型安全。

import superjson from 'superjson'

export default defineEventHandler(() => {
  const data = {
    createdAt: new Date(),

    // Workaround the type conversion
    toJSON() {
      return this
    }
  }

  // Serialize the output to string, using superjson
  return superjson.stringify(data) as unknown as typeof data
})
<script setup lang="ts">
import superjson from 'superjson'

// `date` is inferred as { createdAt: Date } and you can safely use the Date object methods
const { data } = await useFetch('/api/superjson', {
  transform: (value) => {
    return superjson.parse(value as unknown as string)
  },
})
</script>

实践指南

通过 POST 请求消费 SSE(服务器推送事件)

SSE(Server-Sent Events)是一种让服务器向浏览器发送实时更新的技术。它允许服务器在有新数据可用时主动推送给客户端,而不是客户端定期轮询服务器来检查是否有新数据。SSE 主要用于实现实时数据流,如股票价格更新、聊天消息、实时日志等场景。

当通过 POST 请求消费 SSE 时,你需要手动处理连接。以下是如何实现这一过程的方法:

// Make a POST request to the SSE endpoint
const response = await $fetch<ReadableStream>('/chats/ask-ai', {
  method: 'POST',
  body: {
    query: "Hello AI, how are you?",
  },
  responseType: 'stream',
})

// Create a new ReadableStream from the response with TextDecoderStream to get the data as text
const reader = response.pipeThrough(new TextDecoderStream()).getReader()

// Read the chunk of data as we get it
while (true) {
  const { value, done } = await reader.read()

  if (done)
    break

  console.log('Received:', value)
}

这段代码的工作流程如下:

  1. 发送一个 POST 请求到指定的端点,请求体包含了要询问AI的问题。
  2. 接收来自服务器的数据流,将数据流通过 TextDecoderStream 解码为文本格式。
  3. 循环读取流中的数据,每次接收到新的数据块就打印出来。

这个代码非常适合像是chatGPT聊天这种形式


重复请求的情况探讨

重复请求的情况探讨(可以最后再看)

据我实测,单纯的使用$fetch,不使用这两个composables,有的时候并不会导致重复请求。

首先说明一下直接调用$fetch会重复请求的情况

  • 第一次打开页面,$fetch直接在<script setup>中调用

对,除此之外我就没有发现了。你可能会很好奇。不是说很容易导致重复请求吗? 这个跟Nuxt的通用渲染模式有关系。在不做多余的配置情况下,只有第一次打开网页的时候,Nuxt才会进行SSR。

进行SSR渲染时候获取一次数据,然后在水合(Hydration)的时候也会获取一次数据。

然后水合之后的网站,就跟普通的传统Vue的项目差不多了!(并不是,特殊情况下吧)

我渲染a页面的时候,a中的函数确实请求了两次。但是我点击一个按钮(利用click事件跳转到其它页面),跳到b页面的时候,b页面的函数并没有获取两次,而是一次。这是因为a页面水合之后,我点击跳到b页面的过程,是水合后,在浏览器执行的js进行渲染的。也就是说这只是单纯的一次客户端渲染

并且,当我把a页面中的获取数据的函数,放在了onMounted中执行,页面也只获取了一次数据。我查阅了Nuxt的文档,并没有发现其有特殊的说明。但是最终在vue3的官方文档中,我找到了说明。SSR的过程中,onMounted和onUpdated都不会被执行

还有个特殊情况,上面的页面跳转,我特意标红了是点击按钮进行跳转的。在后面的渲染模式文章中,我们还知道,在SSR中,Nuxt会将当前的页面中的所有a标签,都会爬取处理,并对他们同样的进行SSR。

所以,上面页面跳转的时候,如果我是通过a标签进行的话,b页面的数据同样也会被请求两次

综上所述,我再整理归纳不用useFetchuseAsyncData的时候,什么情况下数据会被请求两次

  1. 第一次打开页面,$fetch直接在<script setup>中调用
  2. 该页面被其他页面所链接,导致出现第一种情况

什么情况下不会请求两次

  1. 通过按钮事件跳转到的页面
  2. 在onMounted或者onUpdated钩子函数中
  3. 点击事件中