列表页写完后,真正决定体验的往往不是数据渲染,而是数据还没来、来了但是空的、请求失败、正在上传这四个瞬间。Vant 加载态与空状态组件正好各管一段:Skeleton 撑住首屏结构,Loading 表示进行中,Empty 接住空结果与错误,Progress 把长任务量化。本文先给一套统一的状态机,再逐个组件落地,最后拼成一个可直接复制的列表页。
先把状态机定下来
常见翻车是四个布尔值各管各的:loading、isEmpty、error、refreshing 同时为真,页面同时显示骨架屏和空图。用一个互斥的状态字段就不会打架。
import { ref } from 'vue';
export type PageStatus = 'loading' | 'content' | 'empty' | 'error';
export function usePageState<T>(fetcher: () => Promise<T[]>) {
const status = ref<PageStatus>('loading');
const list = ref<T[]>([]) as { value: T[] };
const load = async () => {
status.value = 'loading';
try {
const data = await fetcher();
list.value = data;
status.value = data.length ? 'content' : 'empty';
} catch (e) {
status.value = 'error';
}
};
return { status, list, load };
}
Loading 就够了。首屏反复闪骨架屏比转圈更打扰人。Skeleton:把真实内容包在 loading 里
van-skeleton 最实用的不是单独渲染,而是用 :loading 包住真实内容——加载时显示占位,加载完成直接切换成子组件,模板里不用写两套 v-if。
<van-skeleton title avatar :row="3" :loading="status === 'loading'" :animate="true">
<article>
<h3>{{ detail.title }}</h3>
<p>{{ detail.summary }}</p>
</article>
</van-skeleton>
| 属性 | 默认值 | 说明 |
|---|---|---|
row | 0 | 段落占位行数 |
row-width | 100% | 支持数字 / 字符串 / 数组,给每行不同宽度 |
title / avatar | false | 是否显示标题、头像占位 |
loading | true | false 时渲染默认插槽的真实内容 |
animate / round | true / false | 动画开关 / 圆角风格 |
title-width / avatar-size / avatar-shape | 40% / 32px / round | 占位尺寸微调 |
默认的三行段落太方正规整时,用 #template 插槽配合 SkeletonImage、SkeletonParagraph 等子组件自由排版,比如做图片 + 多行文本的卡片骨架:
<van-skeleton :loading="status === 'loading'">
<template #template>
<div style="display: flex; width: 100%; gap: 16px">
<van-skeleton-image image-size="96" />
<div style="flex: 1">
<van-skeleton-paragraph row-width="60%" />
<van-skeleton-paragraph />
<van-skeleton-paragraph row-width="40%" />
</div>
</div>
</template>
<van-card :title="goods.name" :thumb="goods.cover" />
</van-skeleton>
--van-skeleton-row-height、--van-skeleton-row-background、--van-skeleton-duration 等 CSS 变量,可配合本站已讲过的 ConfigProvider 统一调整,不必逐个写死。Loading:局部、文案与自定义图标
type 有 circular(默认)与 spinner 两种;size 默认 30px,color 同时影响图标与文字,只想改文字用 text-color;vertical 让图标与文案上下排列。
<div v-if="list.length" class="list-footer">
<van-loading v-if="loadingMore" size="20px" type="spinner" color="#1989fa">
加载中...
</van-loading>
<span v-else>{{ finished ? '没有更多了' : '上拉加载更多' }}</span>
</div>
<van-loading v-if="submitting" vertical size="24px">正在提交</van-loading>
<van-loading vertical>
<template #icon><van-icon name="star-o" size="30" /></template>
自定义图标
</van-loading>
整页遮罩式的加载更适合用 showLoadingToast({ forbidClick: true })(Toast 家族,本站已有专文),它自带遮罩与手动关闭;van-loading 则负责行内、按钮内、区块内这些局部位置。
Empty:四种内置图与业务动作
image 可取 default / error / network / search,也可以直接传图片 URL。image-size 支持数字、带单位字符串,或 [宽, 高] 数组;默认插槽放底部的操作按钮。
<van-empty
v-if="status === 'empty'"
image="search"
description="没有找到相关商品"
>
<van-button round type="primary" size="small" @click="clearFilter">
清空筛选条件
</van-button>
</van-empty>
<van-empty
v-else-if="status === 'error'"
image="network"
:image-size="[120, 90]"
description="网络开小差了"
>
<van-button round size="small" @click="load">重新加载</van-button>
</van-empty>
| image 取值 | 适用状态 | 建议动作 |
|---|---|---|
search / default | 筛选无结果、列表为空 | 清空条件、去逛逛 |
network | 请求超时 / 断网 | 重新加载 |
error | 服务端错误、页面异常 | 返回上一页、联系客服 |
#image 与 #description 插槽可以换成品牌插画和带链接的说明文案;但底部按钮请留在默认插槽里,这样层级与间距仍由组件统一控制。Progress:把长任务的进度量化
percentage 是必填的核心值;show-pivot 控制是否显示进度文字,pivot-text 自定义文案,color 支持渐变,inactive 置灰,track-color 改轨道色,stroke-width 改粗细(默认 4px)。#pivot 插槽暴露 { percentage },可放图标。
<van-progress
:percentage="uploadPercent"
:stroke-width="6"
:show-pivot="uploadPercent < 100"
pivot-text="上传中"
/>
<van-progress
:percentage="75"
color="linear-gradient(to right, #be99ff, #7232dd)"
pivot-text="3/4 步"
/>
<van-progress :percentage="uploadPercent" inactive />
<van-progress :percentage="uploadPercent">
<template #pivot="{ percentage }">
<van-icon name="fire" /> {{ percentage }}%
</template>
</van-progress>
和本站《Vant Uploader 文件上传全解》配合时,把 onProgress 回写出的百分比直接绑到 percentage 即可;注意服务端返回的进度可能超过 100 或跳变,写之前先用 Math.min(100, Math.max(0, v)) 夹一下。
组合实战:一个列表页的四种状态
<script setup>
import { ref, onMounted } from 'vue';
import { usePageState } from './usePageState';
import { fetchGoods } from './api';
const { status, list, load } = usePageState(fetchGoods);
const loadingMore = ref(false);
const finished = ref(false);
const uploadPercent = ref(0);
onMounted(load);
const onLoadMore = async () => {
if (loadingMore.value || finished.value) return;
loadingMore.value = true;
try {
const more = await fetchGoods({ page: 2 });
list.value.push(...more);
finished.value = more.length === 0;
} finally {
loadingMore.value = false;
}
};
</script>
<template>
<!-- 首屏:骨架屏 -->
<van-skeleton v-if="status === 'loading'" title :row="3" :row-width="['100%', '80%', '60%']" />
<!-- 空结果 / 失败 -->
<van-empty v-else-if="status === 'empty'" image="search" description="暂无商品">
<van-button round size="small" type="primary" @click="load">刷新一下</van-button>
</van-empty>
<van-empty v-else-if="status === 'error'" image="network" description="加载失败">
<van-button round size="small" @click="load">重新加载</van-button>
</van-empty>
<!-- 正常内容 -->
<template v-else>
<van-cell v-for="item in list" :key="item.id" :title="item.name" :label="item.desc" />
<div class="list-footer">
<van-loading v-if="loadingMore" size="20px" type="spinner">加载中...</van-loading>
<span v-else>{{ finished ? '没有更多了' : '上拉加载更多' }}</span>
</div>
<van-progress v-if="uploadPercent > 0 && uploadPercent < 100" :percentage="uploadPercent" />
</template>
</template>
常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
| 骨架屏和空状态同时出现 | 多个布尔标志各自为政 | 收敛成互斥的 status 字段 |
| 数据到达时页面明显跳动 | 骨架尺寸与真实卡片不一致 | 用 row-width / #template 对齐真实布局 |
| 加载完成后骨架不消失 | loading 一直为 true | 请求结束(含 catch)统一改状态 |
| 空状态图片太大或变形 | 只传了一个尺寸且比例被拉伸 | 用 :image-size="[宽, 高]" 分别指定 |
| 进度条文字挡住内容 | 默认显示 pivot | :show-pivot="false" 或改用 #pivot 插槽 |
| 暗黑模式下骨架发白刺眼 | 硬编码颜色 | 让 --van-skeleton-row-background 跟随主题变量 |
总结
加载态不是「加个转圈」这么简单:首屏用 Skeleton 保持结构,进行中用 Loading 给反馈,结果为空或失败用 Empty 给出下一步,长任务用 Progress 说明还要多久。四个组件各守一段,配合一个互斥状态机,页面的每个空档都有交代。
用户能容忍等待,但不能容忍不知道自己在等什么。
—— 状态可见,比动画好看重要得多