Tips & Tricks (更新: 2026/6/2)

Claude Code 骨架屏加载实战:React、CLS 与可访问性

用 Claude Code 实现骨架屏加载:React 示例、CLS 防护、可访问性和常见失败案例。

Claude Code 骨架屏加载实战:React、CLS 与可访问性

骨架屏加载是在数据还没有回来时,先显示页面的大致结构。例如图片位置、标题位置、摘要位置先用浅色块占住。换成更直白的话说,就是在真实内容到达前,先把页面的座位排好。

加载 spinner 只能告诉用户“还在处理”。骨架屏可以进一步告诉用户“这里会出现什么类型的内容”。如果图片、广告位或 API 结果进入的是已经预留好的空间,页面就不容易突然跳动。这个稳定性与 web.dev 对 Cumulative Layout ShiftCore Web Vitals 的说明直接相关。

本文会说明如何让 Claude Code 生成可维护的骨架屏加载:包括提示词、可复制运行的 React 示例、CSS 动效、可访问性、失败案例和轻量检查。相关主题可以继续阅读 Claude Code 性能优化图片懒加载可访问性实现流程

先拆清楚要做什么

骨架屏不是把几个灰色矩形放到页面上就结束。真实功能要处理加载中、成功、空状态、错误状态,而且这些状态最好共享稳定的布局。如果只对 Claude Code 说“做一个好看的骨架屏”,它很可能先做出漂亮的 shimmer 效果,却漏掉错误处理、减少动态效果或屏幕阅读器提示。

flowchart LR
  P["给 Claude Code 的指令"] --> S["尺寸接近的骨架屏"]
  S --> D["真实数据"]
  D --> E["空状态"]
  D --> X["错误状态"]
  S --> A["aria-busy / status"]
  S --> M["prefers-reduced-motion"]
  S --> C["CLS 检查"]

可以从这样的提示词开始:

Read the existing card/list components before editing.
Implement skeleton loading only for the article cards list.
Keep the skeleton dimensions close to the loaded content.
Handle loading, empty, error, and success states.
Respect prefers-reduced-motion and avoid layout shift.
Add a small Playwright check if the project already uses Playwright.
Do not change unrelated styles, routing, or data fetching.

prefers-reduced-motion 是一种 CSS 条件,用来判断用户是否在系统或浏览器中选择了“减少动态效果”。强烈的 shimmer 动画可能让部分用户不舒服,因此实现时应参考 MDN 的 prefers-reduced-motion,提供静态版本。

适合使用的实际场景

骨架屏最适合“用户知道大概会出现什么,但数据还不能显示”的页面。

场景需要预留什么注意点
文章卡片列表缩略图、两行标题、摘要、标签固定媒体区域高度,避免卡片忽高忽低
数据看板KPI 卡片、图表框、最近活动不要先显示可能误导用户的部分数字
电商商品网格商品图、名称、价格、评分刷新期间不要展示过期价格或库存
后台表格表头、行、操作按钮区域行数变化很大时,也要检查分页和筛选
咨询或内容产品落地页案例卡、CTA、FAQ 区块CTA 晚出现会把转化路径推到更低的位置

Masa 在 ClaudeCodeLab 的咨询入口中测试时,最有用的改动是先固定文章卡片和 CTA 的高度。反过来,如果骨架屏比真实内容高很多,加载完成时页面会向上跳。骨架屏不是装饰,而是和最终布局之间的约定。

可复制运行的 React 示例

下面的代码可以直接放进 Vite + React + TypeScript 项目的 src/App.tsx。示例用 setTimeout 模拟延迟,并提供成功、空状态、错误状态的切换。让 Claude Code 修改真实项目之前,最好也把状态说明到这个粒度。

import { useEffect, useState } from "react";
import "./skeleton-demo.css";

type Article = {
  id: number;
  title: string;
  description: string;
  tag: string;
};

type LoadState = "loading" | "success" | "empty" | "error";

const demoArticles: Article[] = [
  {
    id: 1,
    title: "用 Claude Code 生成更安全的 UI 差异",
    description: "先读取现有组件,再只改善卡片列表的加载体验。",
    tag: "UX",
  },
  {
    id: 2,
    title: "不增加 CLS 的图片占位方式",
    description: "在真实数据到来前,固定媒体、标题和摘要区域的高度。",
    tag: "Performance",
  },
  {
    id: 3,
    title: "可访问的加载状态",
    description: "组合 aria-busy、status 消息和减少动态效果的处理。",
    tag: "A11y",
  },
];

function SkeletonLine({ width = "100%" }: { width?: string }) {
  return <span className="sk-line" style={{ width }} aria-hidden="true" />;
}

function ArticleCardSkeleton() {
  return (
    <article className="article-card is-skeleton" aria-hidden="true">
      <div className="sk-media" />
      <div className="article-card__body">
        <SkeletonLine width="46%" />
        <SkeletonLine />
        <SkeletonLine width="86%" />
        <SkeletonLine width="32%" />
      </div>
    </article>
  );
}

function ArticleCard({ article }: { article: Article }) {
  return (
    <article className="article-card">
      <div className="article-card__media">{article.tag}</div>
      <div className="article-card__body">
        <p className="article-card__tag">{article.tag}</p>
        <h2>{article.title}</h2>
        <p>{article.description}</p>
      </div>
    </article>
  );
}

export default function App() {
  const [state, setState] = useState<LoadState>("loading");
  const [articles, setArticles] = useState<Article[]>([]);

  useEffect(() => {
    const timer = window.setTimeout(() => {
      setArticles(demoArticles);
      setState("success");
    }, 1200);

    return () => window.clearTimeout(timer);
  }, []);

  const reloadAs = (nextState: LoadState) => {
    setState("loading");
    setArticles([]);

    window.setTimeout(() => {
      setArticles(nextState === "success" ? demoArticles : []);
      setState(nextState);
    }, 700);
  };

  return (
    <main className="demo-shell">
      <div className="demo-toolbar" aria-label="切换显示状态">
        <button onClick={() => reloadAs("success")}>成功</button>
        <button onClick={() => reloadAs("empty")}>空状态</button>
        <button onClick={() => reloadAs("error")}>错误</button>
      </div>

      <section
        aria-busy={state === "loading"}
        aria-describedby="article-list-status"
        className="article-grid"
      >
        <p id="article-list-status" className="sr-only" role="status">
          {state === "loading" ? "正在加载文章列表" : "文章列表已加载完成"}
        </p>

        {state === "loading" &&
          Array.from({ length: 3 }).map((_, index) => (
            <ArticleCardSkeleton key={index} />
          ))}

        {state === "success" &&
          articles.map((article) => (
            <ArticleCard key={article.id} article={article} />
          ))}

        {state === "empty" && (
          <div className="state-panel">暂时没有匹配的文章。</div>
        )}

        {state === "error" && (
          <div className="state-panel" role="alert">
            文章列表加载失败。请稍后再试。
          </div>
        )}
      </section>
    </main>
  );
}

可访问性上最重要的一点是,不要让屏幕阅读器读出每一条灰色线。上面的例子把骨架卡片设为aria-hidden,只用一个role="status"说明列表状态。ARIA 的status适合非紧急状态更新,细节可以查看 MDN 的 ARIA: status role

固定尺寸和动画的 CSS

把下面的 CSS 保存为src/skeleton-demo.css。重点是让加载中和加载后的min-height、媒体区域高度、正文间距保持接近。动画要克制,并且在用户选择减少动态效果时停止。

:root {
  color: #18212f;
  background: #f6f7f9;
  font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}

button {
  min-height: 40px;
  border: 1px solid #b8c2d6;
  border-radius: 8px;
  background: #ffffff;
  color: #18212f;
  padding: 0 14px;
  font-weight: 700;
}

.demo-shell {
  width: min(1040px, calc(100% - 32px));
  margin: 40px auto;
}

.demo-toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-bottom: 18px;
}

.article-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 16px;
}

.article-card {
  min-height: 316px;
  overflow: hidden;
  border: 1px solid #d7deea;
  border-radius: 8px;
  background: #ffffff;
}

.article-card__media,
.sk-media {
  display: grid;
  min-height: 148px;
  place-items: center;
  background: #dfe7f3;
  color: #39506f;
  font-weight: 800;
}

.article-card__body {
  display: grid;
  gap: 10px;
  padding: 18px;
}

.article-card__tag {
  color: #3b6b4f;
  font-size: 0.875rem;
  font-weight: 800;
}

.article-card h2 {
  min-height: 56px;
  margin: 0;
  font-size: 1.16rem;
  line-height: 1.45;
}

.article-card p {
  margin: 0;
  line-height: 1.7;
}

.sk-line,
.sk-media {
  border-radius: 8px;
  background: linear-gradient(90deg, #d9e0ea 25%, #edf1f7 37%, #d9e0ea 63%);
  background-size: 240% 100%;
  animation: skeleton-shimmer 1.4s ease-in-out infinite;
}

.sk-line {
  display: block;
  height: 16px;
}

.state-panel {
  min-height: 180px;
  display: grid;
  place-items: center;
  border: 1px solid #d7deea;
  border-radius: 8px;
  background: #ffffff;
  padding: 24px;
  text-align: center;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

@keyframes skeleton-shimmer {
  from {
    background-position: 120% 0;
  }

  to {
    background-position: -120% 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .sk-line,
  .sk-media {
    animation: none;
    background: #d9e0ea;
  }
}

这段 CSS 优先保证布局稳定,而不是追求夸张的光效。如果骨架线条宽度过于随机,真实内容出现时会像切换到另一个设计。更实用的做法是提前约定两行标题、两行摘要和固定媒体区域。

用 Playwright 做最小检查

如果项目已经使用 Playwright,可以加入一个小测试。它不能证明真实用户环境下的 CLS 完全合格,但能在代码评审前抓住明显退化。

import { expect, test } from "@playwright/test";

test("article skeleton keeps a stable card area", async ({ page }) => {
  await page.goto("/");

  await expect(page.getByText("正在加载文章列表")).toBeAttached();
  await expect(page.locator(".is-skeleton")).toHaveCount(3);

  const firstBox = await page.locator(".article-card").first().boundingBox();
  expect(firstBox?.height).toBeGreaterThan(280);

  await page.getByRole("button", { name: "错误" }).click();
  await expect(page.getByRole("alert")).toContainText("加载失败");
});

真实的 CLS 还会受图片、广告、字体、第三方脚本、网络和设备影响。因此这个测试只是早期报警器,最终仍需要结合 web.dev 的 CLS 指南和线上数据判断。

常见失败和陷阱

第一个失败是骨架屏尺寸和最终内容不一致。加载时看起来很好,真实卡片出现后却伸缩明显。图片要指定widthheightaspect-ratio,广告和 CTA 也要在渲染前预留位置。

第二个失败是所有快速请求都显示骨架屏。如果请求通常 100ms 内完成,骨架屏反而会制造闪烁。实务中可以采用“超过 300ms 才显示”“只在首次加载显示”“刷新时保留旧数据”等规则。

第三个失败是可访问性提示过多。每张卡片都放role="status"可能导致重复播报。应把 live message 放在列表级别,视觉骨架本身隐藏给辅助技术。

第四个失败是没有错误状态。API 失败时仍一直显示骨架屏,用户会以为页面还在加载。错误、空状态和重试应作为独立状态设计。

第五个失败是把产品优先级交给 Claude Code 决定。Claude Code 可以读文件、生成差异,但哪个 CTA 必须保持可见、预留多少广告空间、先展示什么信息,仍然是人的判断。

让 Claude Code 做批判性 review

实现后,不要直接发布。可以切换到 review 模式:

Review only the skeleton loading changes.
Check whether loaded content and skeleton content reserve similar space.
Check loading, success, empty, and error states.
Check reduced-motion behavior and ARIA announcements.
Point out any code that may increase CLS or create repeated screen reader messages.
Return findings with file names and exact lines.

这能让 Claude Code 从“实现者”变成“审查者”。文章站点里,广告、相关文章、CTA 和懒加载图片都会影响同一条视觉流。可以结合 CSS 样式实践指南测试策略,把视觉检查、读屏检查和回归测试分开处理。

与变现路径的关系

骨架屏不仅是 UX 优化,也会保护变现路径。咨询 CTA、产品卡、邮件订阅或广告位如果晚出现并把正文推下去,读者会丢失阅读位置,甚至点到错误区域。这会影响信任和转化。

个人开发者可以先使用免费 Claude Code cheatsheet固定每次 UI 修改的检查步骤。团队如果想把骨架屏、图片懒加载、Core Web Vitals 和可访问性变成统一的工程流程,可以查看 Claude Code 培训与咨询。Masa 可以基于现有仓库整理改进顺序、提示词、组件和检查项。

总结

好的骨架屏加载不是掩盖等待时间的特效,而是提前预留接近最终画面的空间,降低不确定感,并减少明显的布局移动。使用 Claude Code 时,请同时给出目标组件、状态、尺寸、可访问性要求和验证命令。

按本文流程实际试过后,最有效的是先固定媒体和标题高度、在列表级别播报加载状态、并为减少动态效果的用户停止动画。只关注 shimmer 外观时,空状态和错误状态容易被推迟,评审也会变慢。让 Claude Code 同时负责实现和验证,才更接近可发布质量。

#Claude Code #骨架屏加载 #React #UX #可访问性
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。