骨架屏怎样实现?和 loading 有什么区别?
骨架屏是在数据回来之前,先按真实内容的布局画出灰色占位块,数据到了再替换成内容。和转圈的 loading 相比,它让用户提前看到页面结构,也因为占位尺寸和真实内容一致而减少布局跳动;但它不会让数据更快到达。
简答
打开一个新闻列表,内容还没加载出来,先看到一排排灰色的长条和方块,位置和真实的标题、图片一模一样,过一会儿灰块被真实内容替换。这些灰块就是骨架屏。
它和转圈的 loading 都是在等数据,区别在于告诉用户的信息不同。loading 只说“在加载”,骨架屏还提前展示了页面长什么样,用户会觉得已经快好了。而且占位块的尺寸和真实内容一致,内容出来时页面不会突然往下挤。
实现上,组件在加载状态下渲染一套结构相同的灰色块,加一个从左到右扫过的渐变动画;数据回来后换成真实内容。也可以在构建时为首屏生成静态骨架,直接写进 HTML,让 JS 还没执行时就能显示。
要注意,骨架屏改善的是等待时的感受,不会让数据更快回来。加载很快的接口直接显示内容就好,否则骨架一闪而过反而晃眼。还要给读屏用户一个“正在加载”的提示,并在用户要求减少动画时关掉闪光效果。
骨架屏和 loading 怎么选
转圈的 loading 适合操作类的等待,比如点击“提交”后等结果,用户关心的是“成功了没有”,不需要知道接下来的页面长什么样。
骨架屏适合内容类的页面,比如列表、详情、卡片流。用户在等的是内容本身,提前看到结构能更快找到要看的位置。
两者的另一个差别在布局稳定性。loading 图标通常很小,内容回来时页面从一个小图标变成一整屏内容,下方元素会被突然推开。MDN 对 CLS 的解释是:它衡量页面元素在非用户操作的情况下发生意外移动的程度,并举例说没写宽高的图片在加载完之前,浏览器不知道它要占多少空间。内容异步回来时把已有元素挤开,也是同一类意外移动。骨架屏的占位尺寸和真实内容一致,替换时位置不变,就不会产生这种偏移。前提是占位尺寸真的和内容一致,如果骨架画得比内容矮,照样会跳。
组件里怎样写一个骨架屏
下面是一个卡片的骨架。占位块用和真实卡片相同的外层结构和尺寸:
function ArticleCard({ article }) {
if (!article) {
return (
<div className="card" aria-hidden="true">
<div className="skeleton" style={{ height: 160 }} />
<div className="skeleton" style={{ width: '80%', height: 20, marginTop: 12 }} />
<div className="skeleton" style={{ width: '50%', height: 16, marginTop: 8 }} />
</div>
);
}
return (
<article className="card">
<img src={article.cover} alt="" width="320" height="160" />
<h3>{article.title}</h3>
<p>{article.author}</p>
</article>
);
}
为什么骨架上加 aria-hidden?因为一堆空的灰块对读屏用户没有意义,读出来只会是噪音。加载状态要在外层容器上告诉他们,下一节讲。
闪光效果用 CSS 渐变加动画实现:
.skeleton {
border-radius: 4px;
background: linear-gradient(90deg, #eee 25%, #f5f5f5 37%, #eee 63%);
background-size: 400% 100%;
animation: shimmer 1.4s ease infinite;
}
@keyframes shimmer {
from { background-position: 100% 50%; }
to { background-position: 0 50%; }
}
@media (prefers-reduced-motion: reduce) {
.skeleton { animation: none; }
}
MDN 说明 prefers-reduced-motion 用来检测用户是否开启了减少非必要动画的系统设置。闪光是装饰性动画,用户开了这个设置就停掉,只保留灰块。
读屏用户怎样知道正在加载
骨架本身隐藏了,就要在列表容器上表达“正在更新”。MDN 介绍 aria-busy="true" 表示元素正在被修改,辅助技术可以等修改完成再通知用户,加载完成后设为 false。
<section aria-busy={loading} aria-label="文章列表">
{loading ? <SkeletonList /> : <ArticleList items={items} />}
</section>
如果希望读屏明确念出“正在加载”,可以另外放一个视觉上隐藏的状态文本。这里用 aria-busy 只是告诉辅助技术内容还没准备好。
骨架屏一闪而过怎么办
接口如果 100 毫秒就回来,骨架刚出现就被替换,用户看到的是一次闪烁,比直接等 100 毫秒更难受。常见的做法是延迟显示:加载开始后等一小段时间(比如 200 到 300 毫秒,数值按产品体验调整),数据还没回来才显示骨架;一旦显示,至少保持一小段时间再替换,避免刚出现就消失。
React 里如果用 Suspense,骨架屏就是 fallback。并排的 Suspense 会各自显示 fallback,想让几块内容一起出现,就把它们包在同一个 Suspense 里,见《Error Boundary 和 Suspense 分别处理什么?》。
首屏骨架怎样在 JS 执行前就显示
单页应用的首屏,HTML 里只有一个空的 <div id="root">,要等 JS 下载执行完才渲染骨架,这段时间仍然是白屏。可以在构建时把首屏骨架的 HTML 和内联样式直接写进 index.html 的根节点里,浏览器解析 HTML 就能画出来,应用挂载后再替换掉。
这样做能让用户更早看到东西。但要清楚它改善的是首次绘制,真正的主要内容(大图、标题)什么时候到,还是取决于数据和资源加载,见《首屏加载慢应该怎样优化?》。
常见追问
骨架屏要做得和真实页面一模一样吗?
不用。只要保持大致布局和每块的尺寸,让替换时不跳动就行。做得过细,维护成本高,页面一改骨架就得跟着改。很多团队只为首屏和几个核心列表做骨架,其他地方用简单的 loading。
白屏检测会把骨架屏当成正常内容吗?
会有这个问题。骨架屏让根节点有了内容,但用户还没看到真东西。所以白屏检测通常把骨架屏的选择器列入“仍在加载”的名单,发现骨架就过一会儿再查,见《怎样检测页面白屏?常见方案有哪些误报?》。
骨架屏能改善 LCP 吗?
一般不能。骨架屏让页面更早画出东西,但 LCP 量的是最大的那块内容什么时候画出来,真正的大图或标题晚到,LCP 照样晚。