Skeleton骨架屏实现的那些细节与优化技巧

打工人文华 交互 阅读 2,385
赞 11 收藏
二维码
手机扫码查看
反馈

骨架屏这玩意儿看着简单,一用就翻车

上周上线前两天,产品甩过来一个需求:首页加载太慢,用户以为卡了,加个骨架屏提升下体验。我心想这不就是 loading 动画升级版?十几行 CSS 搞定的事,结果硬是折腾了一整天,还改了好几轮。

Skeleton骨架屏实现的那些细节与优化技巧

最离谱的是,第一次提交后 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-imageanimation 全去掉了,只保留灰色块占位:

.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 一模一样,差不多就行

这个方案不是最优的,但最简单,也最不容易出幺蛾子。如果你有更好的实现方式欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论