写在前面
项目初期我根本没管加载态。点进一个页面,数据没回来就是一片白,回来了内容"啪"地一下全部出现。
自己测的时候没觉得有什么问题——网速快、数据熟,白屏不到半秒。但拿给别人看的时候才发现,很多人第一反应是"这页面是不是坏了",然后就会去点别的地方。
这篇记录一下我后来怎么把加载态统一起来的过程。核心是一个很小的组件,但它解决的事情比看起来多。
一、为什么不用转圈动画
最常见的做法是中间放一个转圈的 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 个,还是有跳变。
六、小结
三点体会:
- 加载态不是装饰,是布局的一部分。 它的价值在于"减少重排",而不是"显得不空"。想清楚这一点,就知道为什么形状要贴合真实内容。
- 形态要按内容分。 一个通用灰条糊弄不了——列表、网格、聊天气泡的形状差异很大,用错了照样跳变。
- 别忘无障碍和深浅色。
prefers-reduced-motion降级、用 token 而非硬编码颜色,这两件事成本很低,但缺了就是明显的缺陷。
这个组件一共不到 180 行,但把它用在十几个地方之后,整个应用的"等待体验"是一致的——不管点进哪个页面,都是同样的节奏:先看到形状,再看到内容。
默认评论
Halo系统提供的评论