从白屏到骨架屏:加载态的统一设计

SailTrack
2026-08-19
点 赞
0
热 度
0
评 论
0
  1. 首页
  2. 后端开发
  3. 从白屏到骨架屏:加载态的统一设计

写在前面

项目初期我根本没管加载态。点进一个页面,数据没回来就是一片白,回来了内容"啪"地一下全部出现。

自己测的时候没觉得有什么问题——网速快、数据熟,白屏不到半秒。但拿给别人看的时候才发现,很多人第一反应是"这页面是不是坏了",然后就会去点别的地方。

这篇记录一下我后来怎么把加载态统一起来的过程。核心是一个很小的组件,但它解决的事情比看起来多。

一、为什么不用转圈动画

最常见的做法是中间放一个转圈的 loading。我一开始也是这么做的,但很快发现它有几个问题:

第一,跳变太明显。 转圈消失、内容出现,整个页面布局会突然重排。如果内容比较高,用户视线会跟着错位。

第二,不传达任何信息。 转圈只说明"在加载",不说明"加载出来是什么"。用户没法预判接下来出现的是一列消息、还是一个卡片网格。

第三,闪烁。 如果数据回来得很快(比如本地缓存),转圈出现一瞬间就消失,看着像故障。

骨架屏解决的是这三点:它用占位的形状提前把布局撑起来,内容到了直接替换掉占位块,位置不变、不重排,而且形状本身就暗示了"这里会有什么"。

二、一个组件,五种形态

我没有做五个组件,而是做了一个支持 variant 的组件:

const props = withDefaults(defineProps<{
  variant?: 'text' | 'list' | 'grid' | 'chat' | 'block';
  count?: number;
  blockHeight?: string;
}>(), {
  variant: 'text',
  count: 1,
  blockHeight: '5rem',
});

五种形态对应五种内容类型:

variant 形状 用在哪儿
text 宽度交替的横线 段落、说明文字
list 圆形头像 + 两行文字 会话列表、通讯录、待办
grid 16:9 缩略图 + 两行文字 云空间文件网格、AI 助手卡片
chat 左右交替的气泡 消息流
block 指定高度的色块 图表、热力图这类整块内容

为什么形态要这么细? 因为骨架屏的作用是"预告布局"。如果列表页用一排通用灰条,内容回来变成"头像+两行文字",用户还是会感觉到跳变。形状接近,替换才无感。

三、几个具体实现细节

列表形态:头像和文字的宽度差

<template v-if="variant === 'list'">
  <div v-for="i in items" :key="i" class="sk-row">
    <span class="sk sk-avatar" />
    <span class="sk-lines">
      <span class="sk sk-line w-60" />
      <span class="sk sk-line w-40" />
    </span>
  </div>
</template>

两行文字用了不同的宽度(60% 和 40%)。如果两行一样宽,看起来像表格;宽度递减才像"标题 + 副标题"。

聊天形态:奇数左、偶数右

.sk-bubble-row:nth-child(odd) { justify-content: flex-start; }
.sk-bubble-row:nth-child(even) { justify-content: flex-end; }

.sk-bubble { width: 56%; height: 2.6rem; }
.sk-bubble-row:nth-child(3n) .sk-bubble { width: 38%; }

两个小设计:左右交替模拟对话双方;每三个气泡收窄一次,避免所有气泡一样长显得假。

这类细节不影响功能,但它决定了骨架屏看起来是"精心做的"还是"随便应付的"。

扫光效果:用 color-mix 而不是硬编码颜色

.sk {
  background: var(--surface-panel-strong);
}

.sk::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--surface-panel) 72%, transparent),
    transparent
  );
  transform: translateX(-100%);
  animation: sk-sweep 1.4s ease-in-out infinite;
}

这里用 color-mix 从已有的 token 算出半透明的高光色,而不是写死一个 rgba(255,255,255,0.3)好处是深色模式不用单独适配——--surface-panel 在深色下自动变成深色值,混合出来的高光也跟着变,不会出现"深色背景上一道刺眼白光"。

四、无障碍:一个容易被忘掉的降级

@media (prefers-reduced-motion: reduce) {
  .sk::after {
    animation: none;
  }
}

有前庭功能障碍的用户会对持续移动的动画不适,操作系统提供了"减少动态效果"开关。关掉动画后骨架块依然在,只是不闪了——功能不丢,只是不动。

另外容器上带了语义:

<div class="app-skeleton" role="status" aria-label="内容加载中">

role="status" 让屏幕阅读器知道这里是动态更新的区域,而不是一片无意义的空 div。

五、用起来是什么样

组件本身很简单,关键在于每个页面挑选合适的 variant

<!-- 会话列表:一行一个人 -->
<AppSkeleton variant="list" :count="6" />

<!-- AI 助手:卡片网格 -->
<AppSkeleton v-if="agentsLoading" variant="grid" :count="6" />

<!-- 消息流:左右气泡 -->
<AppSkeleton variant="chat" :count="5" />

<!-- 考勤指标:几行文字 -->
<AppSkeleton v-if="attendanceLoading" variant="text" :count="4" />

<!-- 图表区:整块 -->
<AppSkeleton v-else-if="workbenchLoading" variant="block" :count="2" block-height="7rem" />

count 控制占位个数。这里有个小经验:占位数量略多于实际预期数量。如果实际会出现 6 条,占位就放 6 个——放 3 个的话,内容回来时会从 3 个变成 6 个,还是有跳变。

六、小结

三点体会:

  1. 加载态不是装饰,是布局的一部分。 它的价值在于"减少重排",而不是"显得不空"。想清楚这一点,就知道为什么形状要贴合真实内容。
  2. 形态要按内容分。 一个通用灰条糊弄不了——列表、网格、聊天气泡的形状差异很大,用错了照样跳变。
  3. 别忘无障碍和深浅色。 prefers-reduced-motion 降级、用 token 而非硬编码颜色,这两件事成本很低,但缺了就是明显的缺陷。

这个组件一共不到 180 行,但把它用在十几个地方之后,整个应用的"等待体验"是一致的——不管点进哪个页面,都是同样的节奏:先看到形状,再看到内容。


让我们忠于理想,让我们面对显示

SailTrack

entp 辩论家

站长

不具版权性
不具时效性

文章内容不具时效性。若文章内容有错误之处,请您批评指正。

目录

欢迎来到SailTrack的站点,为您导航全站动态

42 文章数
11 分类数
5 评论数
53标签数