How to Handle Data Loading Elegantly in Vue

如何优雅的于 Vue 正确的处理数据加载

背景

索取并显示数据对前端工程师来讲是家常便饭,但项目复杂化的状态管理往往导致混乱。希望通过这篇文章提出解决方案和记录过程。

虽然文章中使用的是 Vue Composition API,不过重点不在于使用的框架而是背后的概念。像是 React 的脉络也是类似的,如果你想从 React 的角度切入推荐这篇文章:为什么你不应该在 React 中直接使用 useEffect 从 API 获取数据🔗

Lv.1:从发送一个简单的请求开始

目前有一个简单的产品 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>

1️⃣ 检讨

需求达成了,但你发现这么做并没有办法显示请求出错或是加载的状态,用户无法知道任何进展!因此下一版本来尝试新增更多状态来处理这个问题。

Lv.2:新增加载与错误状态

在这个版本使用更多状态像是 isLoadingerrorMessage 来记录请求的状态,并且通过这些状态来驱动 UI 给用户更多提示。

<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>

2️⃣ 检讨

到这里用户使用体验已经接近完善了,但开发体验却不尽人意,因为当规模扩大或需求变更时,组件终将会塞满各式各样的状态,光是理解这些状态避免切换错误就是一件很累人且容易出错的事。除此之外加载过程的版面偏移🔗造成的闪烁除了影响用户体验之外,重新计算页面布局也会造成性能上的问题,下一版本来尝试制作 UI 并解决这个问题。

Lv.3:添加 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>

3️⃣ 检讨

到这个步骤,用户体验已非常完美,但在状态管理方面则可以考虑以下几种方案来增进开发体验。

Lv.3-1:通过组合式函数(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>
<SkeletonCard />
</div>
</template>

细节实现可以参考官方案例🔗VueUse 的实现🔗或是 Nuxt 实现🔗,在这之上可以扩充更多功能像是缓存、重试、渲染模式切换……等进阶功能。

Lv.3-2:使用实验性 <Suspense> 组件

<Suspense> 实际上就是一个「Vue 的默认组件用于处理异步加载的组件」,可以在异步组件加载完成之前显示默认内容,<Suspense> 会接受两个插槽🔗分别是 defaultfallback,它们用途也很明显:default 用于放入异步组件,fallback 则是放入默认组件(加载告示之类的讯息)。

<template>
<!-- 默认组件不需要引入,可以直接在 Template 当中使用 -->
<Suspense>
<template #default>
<!-- 放入异步组件 -->
<AsyncProductCard />
</template>
<template #fallback>
<SkeletonCard />
</template>
</Suspense>
</template>

所谓的异步组件其实就是两种可能:1. async setup() 或是 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>

至于错误处理可以使用 Vue3 的 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>
</template>

总结

结果总览,5 种索取的范例

文章循序渐进的展示如何在 Vue 中实践改良索取数据的用户与开发体验,有将过程记录在 Vue-Fetch-Demo🔗 这个文件之中,欢迎自由索取练习。

延伸阅读