列表页写完后,真正决定体验的往往不是数据渲染,而是数据还没来、来了但是空的、请求失败、正在上传这四个瞬间。Vant 加载态与空状态组件正好各管一段:Skeleton 撑住首屏结构,Loading 表示进行中,Empty 接住空结果与错误,Progress 把长任务量化。本文先给一套统一的状态机,再逐个组件落地,最后拼成一个可直接复制的列表页。

先把状态机定下来

常见翻车是四个布尔值各管各的:loading、isEmpty、error、refreshing 同时为真,页面同时显示骨架屏和空图。用一个互斥的状态字段就不会打架。

页面状态流转
loading(首屏)→ Skeleton
↓
content(有数据)→ 列表 + 底部 Loading
↓
empty(无数据)/ error(请求失败)→ Empty
↓
重试 / 下拉刷新 → 回到 loading 或局部 loading
ts usePageState.ts(业务封装,非 Vant API)
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。

html 基础用法
<van-skeleton title avatar :row="3" :loading="status === 'loading'" :animate="true">
  <article>
    <h3>{{ detail.title }}</h3>
    <p>{{ detail.summary }}</p>
  </article>
</van-skeleton>
属性默认值说明
row0段落占位行数
row-width100%支持数字 / 字符串 / 数组,给每行不同宽度
title / avatarfalse是否显示标题、头像占位
loadingtruefalse 时渲染默认插槽的真实内容
animate / roundtrue / false动画开关 / 圆角风格
title-width / avatar-size / avatar-shape40% / 32px / round占位尺寸微调

默认的三行段落太方正规整时,用 #template 插槽配合 SkeletonImage、SkeletonParagraph 等子组件自由排版,比如做图片 + 多行文本的卡片骨架:

html 自定义卡片骨架
<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>
⚠️
别让骨架屏和真实布局差太远
骨架的宽高应尽量贴近真实卡片,否则数据到达瞬间会出现明显跳动(CLS)。Vant 提供 --van-skeleton-row-height、--van-skeleton-row-background、--van-skeleton-duration 等 CSS 变量,可配合本站已讲过的 ConfigProvider 统一调整,不必逐个写死。

Loading:局部、文案与自定义图标

type 有 circular(默认)与 spinner 两种;size 默认 30px,color 同时影响图标与文字,只想改文字用 text-color;vertical 让图标与文案上下排列。

html 列表底部加载更多
<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 支持数字、带单位字符串,或 [宽, 高] 数组;默认插槽放底部的操作按钮。

html 区分空结果与加载失败
<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 },可放图标。

html 上传进度与步骤进度
<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)) 夹一下。

组合实战:一个列表页的四种状态

vue GoodsList.vue
<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 说明还要多久。四个组件各守一段,配合一个互斥状态机,页面的每个空档都有交代。

用户能容忍等待,但不能容忍不知道自己在等什么。
—— 状态可见,比动画好看重要得多