Skeleton骨架屏实现的那些细节与优化技巧
骨架屏这玩意儿看着简单,一用就翻车
上周上线前两天,产品甩过来一个需求:首页加载太慢,用户以为卡了,加个骨架屏提升下体验。我心想这不就是 loading 动画升级版?十几行 CSS 搞定的事,结果硬是折腾了一整天,还改了好几轮。
最离谱的是,第一次提交后 QA 说“你这个骨架屏比数据还晚出来”,我当时人都傻了——合着我是搞了个反向优化?
第一版:直接套组件库的 Skeleton,完蛋
我一开始图省事,直接用了项目里已有的 UI 库(Ant Design),它自带 Skeleton 组件。代码贼简单:
import { Skeleton, Card } from 'antd';
function ArticleList({ loading, articles }) {
if (loading) {
return (
<div>
<Skeleton active paragraph={{ rows: 3 }} />
<Skeleton active paragraph={{ rows: 3 }} />
<Skeleton active paragraph={{ rows: 3 }} />
</div>
);
}
return (
<div>
{articles.map(article => (
<Card key={article.id}>{article.title}</Card>
))}
</div>
);
}
看起来没问题吧?本地跑起来也挺顺。但一上测试环境就出事:接口慢的时候,骨架屏要等 loading = true 触发后才渲染,而这个状态是在请求发起时设置的。但因为 React 渲染机制 + 网络延迟感知滞后,经常出现“白屏 1 秒 → 骨架闪现 → 内容出来”的情况,用户体验还不如纯白屏。
这里我踩了个坑:**骨架屏必须尽早出现,最好在路由切换瞬间就展示**,而不是等请求发出去再告诉 UI “我要 loading 了”。
第二版:提前占位,CSS 手动写一套
后来我试了下发现,得把骨架屏做成静态结构,页面一进来就渲染,不管数据有没有开始请求。于是我把骨架部分抽成独立组件,用纯 CSS 实现动画:
.skeleton-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fff;
}
.skeleton-title {
width: 60%;
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
.skeleton-paragraph {
height: 14px;
background: #f0f0f0;
border-radius: 4px;
margin: 4px 0;
animation: loading 1.5s infinite;
background-size: 200% 100%;
}
.skeleton-paragraph:nth-child(1) {
width: 90%;
animation-delay: 0.1s;
}
.skeleton-paragraph:nth-child(2) {
width: 80%;
animation-delay: 0.3s;
}
.skeleton-paragraph:nth-child(3) {
width: 70%;
animation-delay: 0.5s;
}
@keyframes loading {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
<div class="skeleton-card">
<div class="skeleton-title"></div>
<div class="skeleton-paragraph"></div>
<div class="skeleton-paragraph"></div>
<div class="skeleton-paragraph"></div>
</div>
然后在主组件里这样处理:
function ArticleList({ articles }) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
// 页面一挂载就显示骨架屏,不等数据
setMounted(true);
}, []);
// 如果还没拿到数据,且 mounted 已触发,则显示骨架
if (!articles || articles.length === 0) {
return (
<>
<div className="skeleton-card">...上面的HTML结构</div>
<div className="skeleton-card">...</div>
<div className="skeleton-card">...</div>
</>
);
}
return (
<div>
{articles.map(article => (
<Card key={article.id}>{article.title}</Card>
))}
</div>
);
}
这么改完,QA 测试说“这次至少有东西了”,但又提了个新问题:**安卓低端机上骨架动画会卡顿,甚至影响主内容渲染速度**。
我拿自己手里的老小米试了下,确实,那个渐变背景动画一跑,FPS 直接掉到 40 多,难怪会卡。
第三版:干掉动画,只留占位,反而更丝滑
后来试了下发现,其实用户根本不在乎动画漂不漂亮,关键是“别白屏”。所以我干脆把 background-image 和 animation 全去掉了,只保留灰色块占位:
.skeleton-base {
background-color: #f5f5f5;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.skeleton-title {
width: 60%;
height: 20px;
margin-bottom: 12px;
}
.skeleton-paragraph {
width: 100%;
height: 14px;
margin: 4px 0;
background-color: #e0e0e0;
border-radius: 2px;
}
/* 不再使用动画 */
JavaScript 部分也简化了逻辑,直接根据是否有数据判断:
function ArticleList({ articles }) {
const hasData = Array.isArray(articles) && articles.length > 0;
if (!hasData) {
return (
<div className="article-list-skeleton">
<div className="skeleton-card">
<div className="skeleton-title"></div>
<div className="skeleton-paragraph"></div>
<div className="skeleton-paragraph"></div>
<div className="skeleton-paragraph"></div>
</div>
{/* 重复几个 */}
</div>
);
}
return (
<div>
{articles.map(article => (
<Card key={article.id}>{article.title}</Card>
))}
</div>
);
}
最关键的一点是,在路由层提前加载这个组件。比如用 React Router 的话:
<Route
path="/articles"
element={
<Suspense fallback={<ArticleListSkeleton />}>
<ArticleList />
</Suspense>
}
/>
或者如果你没用 Suspense,至少要在 useEffect 里尽快发起请求,并确保组件 mount 后立刻 render 骨架结构。
关于时机:什么时候该显示骨架屏?
折腾了半天发现,**骨架屏的核心不是长得像不像,而是出现得够不够快**。有几个关键点:
- 不要等 API 请求发出后再决定是否 show skeleton,应该页面一打开就 render
- 如果用了 SSR 或 SSG,服务端能预判是否需要骨架,那就更好了
- 对于客户端,建议在
useState(false)初始状态时直接返回 skeleton,而不是通过异步 state 控制
还有一个细节:**别忘了给骨架屏设最大显示时间**。我后来加上了防呆设计:
function ArticleListWithTimeout({ articles }) {
const [showFallback, setShowFallback] = useState(false);
useEffect(() => {
const timer = setTimeout(() => {
setShowFallback(true);
}, 300); // 超过300ms没数据就显示骨架
return () => clearTimeout(timer);
}, []);
const hasData = Array.isArray(articles) && articles.length > 0;
if (!hasData && showFallback) {
return <SkeletonUI />;
}
return <RealContent articles={articles} />;
}
这样既避免了极快网络下骨架屏“闪一下”的尴尬,又保证了慢网络下的体验。
最后的小问题:样式复用麻烦,但懒得搞抽象
现在每个列表页都得抄一遍 .skeleton-card 的结构和类名,有点烦。理论上可以封装一个通用 Skeleton 组件,传行数、宽度配置进去。但我试了下发现配置项越来越多,最后又回到 UI 库那种模式,灵活性反而下降。
所以目前就这样了——虽然不完美,但至少上线后没人再投诉加载体验差了。改完后还有一两个小问题,比如横屏时某些圆角对不上,但无大碍。
总结:简单粗暴反而最有效
以上是我踩坑后的总结。说实话,骨架屏这东西技术含量不高,但特别容易被忽视细节。我的建议是:
- 优先考虑出现时机,其次才是视觉效果
- 动画能省则省,尤其在低端设备上
- CSS 占位比 JS 控制更可靠
- 别追求和真实 UI 一模一样,差不多就行
这个方案不是最优的,但最简单,也最不容易出幺蛾子。如果你有更好的实现方式欢迎评论区交流。

暂无评论