前言
获取和显示数据对于前端工程师来说是家常便饭的任务,但随着背后状态的增多,整个项目可能变得非常混乱。
从发送一个简单的请求开始
我们有一个简单的产品 API,需求是获取数据并显示在页面上。我们使用 JS 原生的 fetch API 和 Async/Await 处理异步请求。
<script setup>
import { ref } from "vue";
const product = ref({});
async function getProduct() {
const productResponse = await fetch("https://dummyjson.com/products/1");
if (!productResponse.ok) {
console.error(productResponse);
return;
}
const productJSON = await productResponse.json();
product.value = productJSON;
}
getProduct();
</script>
<template>
<div>
<img :src="product.thumbnail" />
<h2>{{ product.title }}</h2>
<p>{{ product.description }}</p>
</div> </template
>
虽然这个请求能够正确地返回数据,但有个问题:**用户无法得知请求是否出错或正在加载。**接下来,我们将通过新增状态来解决这个问题。
新增加载与错误状态
在这个版本中,我们添加了 isLoading
和 errorMessage
状态,用来记录请求的状态,并通过这些状态来给用户更多的提示。
<script setup>
import { ref } from "vue";
const { productUrl } = defineProps({
productUrl: {
type: String,
default: "https://dummyjson.com/products/1",
},
});
const product = ref({});
const isLoading = ref(true);
const errorMessage = ref("");
getProduct(productUrl);
async function getProduct(productUrl) {
isLoading.value = true;
const productResponse = await fetch(productUrl);
if (!productResponse.ok) {
console.error(productResponse);
errorMessage.value = (await productResponse.json()).message;
return;
}
const productJSON = await productResponse.json();
product.value = productJSON;
isLoading.value = false;
}
</script>
<template>
<div id="app">
<div class="mx-auto max-w-fit bg-white p-4 text-black">
<div class="bg-red-300 p-4" v-if="errorMessage">{{ errorMessage }}</div>
<!-- 在这里判断是否请求中 -->
<div v-else-if="!isLoading">
<img :src="product.thumbnail" />
<h2>{{ product.title }}</h2>
<p>{{ product.description }}</p>
</div>
<div v-else>Loading...</div>
</div>
</div> </template
>
到这里用户使用体验已经接近完善了,但开发体验却不尽人意,因为当需求变更时,组件终将会塞满各式各样的状态,光是理解这些状态避免切换错误就是一件很累人且容易出错的事。
除此之外加载过程的版面偏移造成的闪烁除了影响用户体验之外,重新计算页面布局也会造成性能上的问题,下一版本来尝试制作 UI 并解决这个问题。
添加 UI 与骨架屏
这次版本除了给数据添加基本样式之外也新增了骨架屏幕,其实就是更贴近实际结果更华丽的 Loader 而已,这么做的好处是可以解决先前遇到的版面偏移。
<template>
<div v-if="errorMessage">{{ errorMessage }}</div>
<div v-else-if="!isLoading">
<ProductCard :product="product" />
</div>
<div v-else>
<SkeletonCard />
</div>
</template>
到这个步骤,用户体验已非常完美,既有数据的展示,也有加载以及错误状态,但在状态管理方面则可以考虑以下几种方案来增进开发体验。
一种方式:通过 Composable 包装请求逻辑
反思处理这些状态的过程,发现其实这些状态都是为了处理数据的请求,因此可以通过封装相关逻辑来处理这些状态,让组件只需要关注数据本身即可。
举例来说,制作一个 useFetch 并输入请求 URL,然后输出数据、错误信息、请求状态等,不用再为每个请求开关状态。
<script setup>
const { data, error } = useFetch("https://dummyjson.com/products/1");
</script>
<template>
<div v-if="error">{{ error.message }}</div>
<div v-else-if="product">
<ProductCard :product="product" />
</div>
<div v-else>
<!-- 插入 4 个骨架屏卡片 -->
<SkeletonCard v-for="i in 4" :key="i" />
</div>
</template>
具体实例可以参考官方文档、Vueuse 的示例或是 Nuxt 的示例,在这之上可以扩展更多功能像是快取、重试(Refresh)、渲染模式(mode)切換……等进阶功能。
另一种方式:使用实验性组件 Suspense
<Suspense>
实际上就是 Vue.js
的默认组件用于处理异步加载的组件。在异步组件加载完成之前,可以显示默认内容。
<Suspense>
有两个插槽,分别是 default
和 fallback
。它们的用途也很明显:default
用于放入异步组件,fallback
则是放入默认组件(如加载提示等信息)。
<template>
<!-- Vue.js 内置組件不需要引入,可以直接在 Template 中使用 -->
<Suspense>
<template #default>
<!-- 放入异步组件 -->
<AsyncProductCard />
</template>
<template #fallback>
<SkeletonCard />
</template>
</Suspense>
</template>
所谓的异步组件实际上有两种可能性:1. async setup()
或者 2. Top level await。
<!-- async setup() -->
<script>
export default {
async setup() {},
};
</script>
<!-- top level await -->
<script setup>
await xxx();
</script>
所以这样我们可以直截了当地制作一个异步组件 AsyncProductCard
(如下),并且通过上一层的 <Suspense>
帮助我们在组件加载完成之前自动显示预设内容。
<script setup>
import ProductCard from '../ProductCard.vue';
import { ref } from 'vue';
const product = ref({});
async function getProduct() {
const productResponse = await fetch('https://dummyjson.com/products/1');
if (!productResponse.ok) {
console.error(productResponse);
// 在请求失败时抛出错误让父组件处理
throw Error('请求失败');
}
const productJSON = await productResponse.json();
product.value = productJSON;
}
await getProduct();
</script>
<template>
<ProductCard :product="product" />
</template>
至于错误处理可以使用 Vue.js@3
的 onErrorCaptured
生命周期,这个生命周期可以捕捉子组件的错误,并且可以在上层组件中处理错误,这样一来请求错误也可以显示反馈给用户了。
<script setup>
import { ref } from "vue";
const error = ref(null);
onErrorCaptured(err => {
error.value = err.message;
});
</script>
<template>
<div v-if="error" class="bg-red-300">Err: {{ error }}</div>
<ProductCard :product="product" />
</template>