一个列表页加了骨架屏(在数据返回前显示"灰色占位块"),期望改善"感知性能"。上线后 A/B 显示:首屏时间的指标没变,而"跳出率"上升了 4%——因为骨架屏"看起来像内容",用户以为"已经加载完",滑动后"什么都没有",于是离开。
骨架屏是"感知优化",而它的效果取决于"骨架是否与最终内容同形"。形状不符时,它比"空状态"更糟——这个"误导"的机制是"骨架与内容共享视觉语言":灰块看起来像"文字的占位",于是用户"按颜色深浅判断加载状态"——而骨架的深浅与真实内容太接近时会失效。
因为它制造了"内容已就绪"的误导。
1. 骨架屏解决的三个问题
| 问题 | 骨架屏是否有效 |
|---|---|
| 白屏(无反馈) | 有效(有视觉反馈) |
| 布局跳动(CLS) | 有效(占位固定尺寸) |
| 感知等待 | 有效(有"在进行"的感觉) |
第二项是"最容易被忽略的真实收益":**骨架屏的"固定高度"能避免"内容进来时整页跳一下"**第二项的"量化指标"就是 CLS:内容进来时"下方元素被推下去"的距离。骨架把这段距离"提前占住",于是 CLS 接近 0——而这一点在"移动端"比桌面端更明显。
——而这一点与"好看"无关,是纯技术收益。
2. 三种"骨架无效/更差"的情形
| 情形 | 原因 |
|---|---|
| 形状与内容不符 | 用户以为加载完了 |
| 骨架停留太久(> 3s) | 焦虑上升("卡住了") |
| 骨架有"假内容"(灰条像文字) | 用户去读,发现是占位 |
第二项有实验支撑:进度不确定时,"看起来很忙"的动画能降低焦虑;而"静止的骨架"超过 2–3 秒会让焦虑上升——因为"停住"等于"卡死"。第二项的"另一个前提"是"骨架要"看起来在动"":静止的灰块与"加载完成但内容为空"在视觉上没有差别——于是"用户以为没数据",而不是"以为在加载"。
// 三种"等待反馈"的适用时长
spinner(转圈):< 1s(短任务,不需要结构)
skeleton(骨架):1–3s(已知结构,即将有内容)
progress bar(进度条):> 3s(长任务,需要"还剩多少")
// 反例:骨架用 10s → 用户以为是内容,反复滑动、然后离开
3. "同形"的三个要求
| 要求 | 说明 |
|---|---|
| 尺寸一致 | 骨架块与真实内容的高度/列数相同 |
| 数量一致 | 骨架的条数 = 首屏条数 |
| 布局一致 | 图文位置不能变 |
第二项常被漏:骨架显示 5 条,实际只返回 2 条第二项的"修法"有两种:骨架条数 = min(首屏条数, 预期返回数),或"内容少时收缩骨架的高度"——前者更简单,后者更平滑。
——于是"内容进来时下面空了一大片",比"直接显示 2 条"更突兀。
"延迟显示"是"避免闪烁"的标准做法,而它需要一个"计时器 + 两种状态":
// 三段状态:idle(不显示)→ skeleton(>300ms 才显示)→ content
1 请求开始:启动 300ms 定时器
2 数据先回来:取消定时器,不显示骨架(无闪烁)
3 定时器先到:显示骨架(有反馈),等数据
// 关键:定时器与"取消"要成对,否则会"数据已到而骨架仍亮着"
4. 三种"实现方式"的代价
| 方式 | 代价 |
|---|---|
| 手写骨架组件 | 需与真实组件"同步维护" |
| 由真实组件"降级"渲染(数据为空时) | 天然同形,但需组件支持 |
| 自动生成(按 CSS 推测) | 准确率有限 |
第二项的"实现要点"是"共用布局常量":行高、列数、内边距都写成常量,骨架与真实内容各引用一次——于是"改一处两边同步"。
第二项是"最省心"的:同一个组件在"数据为空"时渲染"骨架态",于是"形状天然一致"——而"维护成本"从"两套"降到"一套"。
// 由真实组件降级渲染(同形的最省做法)
function Row({ item }) {
if (!item) return <SkeletonRow />; // 骨架态与真实态共用布局常量
return <div className="row">...</div>;
}
// 关键:骨架与真实行都用同一个 ROW_HEIGHT 常量,改一处两边同步
5. 怎么判断"骨架有用"
| 指标 | 读法 |
|---|---|
| 感知性能(用户评分) | 直接 |
| 跳出率/回访 | 变差要警惕 |
| CLS(布局偏移) | 应下降 |
| 首屏可交互时间 | 不该变差 |
第二项是"这轮教训的来源":骨架屏可能"改善感知"但"增加跳出"第二项的"读法"要分群:"新用户跳出上升"与"老用户不受影响" 是两种不同的信号——前者说明"误导",后者说明"变化可接受"。
——因为"误以为加载完"导致"滑动后发现空"。
第三项的"实测方式"很直接:DevTools 的 Layout Shift 高亮会圈出"跳动的元素"——若骨架阶段没有高亮而内容阶段有,说明"骨架没占住位"。
第三项是"技术上应该改善"的:如果 CLS 没降,说明骨架尺寸与内容不符。
"骨架与真实内容"的高度可以用一个常量表对齐,避免"两处改动不同步":
// 布局常量(骨架与内容都引用)
ROW_HEIGHT = 72px
ROW_GAP = 12px
FIRST_SCREEN_ROWS = 5
// 骨架高度 = ROW_HEIGHT × 条数 + GAP × (条数-1)
// 内容高度由同一常量决定 → 天然一致
6. 排错:五种"骨架相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 用户以为加载完了 | 形状/数量不符 | 同形 + 数量一致 |
| 内容进来时跳动 | 尺寸不同 | 固定高度常量 |
| 骨架停很久 | 数据慢 | 超时后转进度条 |
| 跳出率上升 | 误导 | 加"加载中"文案 |
| 维护两套组件 | 手写骨架 | 用降级渲染 |
第四项很便宜:**在骨架上方加一行"加载中…"**第四项的"替代"是"骨架带微光动画 + 一行小字":文字负责"说明状态",动画负责"说明在动"——两者分工,比"只加一个"更清楚。
,成本几乎为零,而它能消除"以为是内容"的误判。
最后一个"无障碍"写法,成本两行:
<!-- 骨架的无障碍表达 -->
<div aria-busy="true" aria-live="polite">
<div class="skeleton" aria-hidden="true"></div>
<span class="sr-only">内容加载中</span>
</div>
<!-- 收益:读屏用户听到"内容加载中",而不是一串空块 -->
7. 边界:什么情况不必用骨架
| 场景 | 为什么 | 做法 |
|---|---|---|
| 加载 < 500ms | 骨架一闪而过(闪烁) | 不用,或延迟 300ms 再显示 |
| 内容形状不固定 | 无法同形 | 用 spinner |
| 首屏是"表单/空状态" | 无可占位结构 | 不用 |
| 已有 SSR(内容已就位) | 无"等待" | 不用 |
第一项的"实现"是一个定时器:数据在 300ms 内返回就"不显示骨架",超过才显示——于是"快的情况"完全无闪烁。
第一项是"技术细节":骨架闪现 100ms 会让页面"闪一下",比"直接出内容"更差——所以"延迟 300ms 再显示骨架"是常见做法。
判断口径一句话:"这段等待是否'结构已知、即将有内容、且超过 1 秒'"。最后一条与"无障碍"有关的边界:骨架应当对屏幕阅读器"不可读"(aria-hidden),并用 aria-busy 表达"正在加载"——否则它会念出一串"空块"。
三者都成立,骨架有用;任一不成立(太快、形状不定、内容已在),它会带来负面影响。
8. 自测
- 骨架屏解决的三个问题里,哪一个是"纯技术收益"?它与"好看"无关在哪里?
- 为什么"静止的骨架"超过 2–3 秒会让焦虑上升?三种等待反馈的时长分界是什么?
- "同形"的三个要求里,哪一项常被漏?它会造成什么?
- "由真实组件降级渲染"为什么最省心?它靠什么保证形状一致?
- 为什么"骨架屏可能改善感知但增加跳出"?
- 判断"该不该用骨架"的那三个条件是什么?