打造有意义的 loading.tsx 与骨架屏
构建路由级加载状态和与最终布局匹配的骨架占位符,避免页面布局偏移。
打造有意义的 loading.tsx 与骨架屏 是 CoddyKit 上的免费 Next.js 15 Fullstack (App Router + Server Actions) 课时。 这是第 2 节课,共 4 节。 你可以在下方免费阅读本课时的完整内容 — 然后在浏览器中使用内置代码编辑器和全天候 AI 导师进行实践。 这是 Next.js 15 Fullstack (App Router + Server Actions) 学习路径的一部分,你的进度在网页和 CoddyKit 应用中同步。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
本课时的部分内容尚未翻译,以英文显示。
Why loading.tsx Exists
In the App Router, a loading.tsx file is special: Next.js automatically wraps your route segment's page.tsx in a React <Suspense> boundary and shows loading.tsx as the fallback while the server component streams.
- You get an instant loading state with zero manual Suspense wiring.
- It only triggers on the server-render of that segment, not on every client interaction.
- The fallback is shown immediately while the page's async data resolves.
This lesson is about making that fallback meaningful so the user perceives speed and the layout does not jump.
The File Convention
Drop a loading.tsx next to your page.tsx in any route segment. Its default export is rendered as the Suspense fallback for that segment and everything below it.
- It is a normal React component — it can be a Server Component (the default) since it renders only static markup.
- No props are passed to it; it must be self-contained.
- Keep it lightweight so it ships and paints fast.
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="dashboard-grid" aria-busy="true">
<DashboardSkeleton />
</div>
);
}The Real Goal: Avoid Layout Shift
A bad loading state replaces your page with a centered spinner, then snaps to a totally different layout when data arrives. That visual jump hurts perceived performance and Cumulative Layout Shift (CLS).
A good skeleton mirrors the final layout:
- Same number of cards, rows, and columns.
- Same approximate widths and heights as the real content.
- Same spacing and grid structure.
When real data swaps in, nothing moves — only the gray placeholders fill with content.
A Reusable Skeleton Primitive
Start with one tiny building block: a Skeleton box that renders a gray, rounded rectangle. You compose everything else from it.
- Accept
classNameso callers control width, height, and shape. - Add an
animate-pulsestyle (Tailwind) or a CSS shimmer for the loading feel. - Mark it
aria-hidden— it is decorative, not real content.
// components/skeleton.tsx
export function Skeleton({ className = '' }: { className?: string }) {
return (
<div
aria-hidden="true"
className={`animate-pulse rounded-md bg-gray-200 ${className}`}
/>
);
}Matching the Final Layout
Build the skeleton by copying the structure of the real component, then replacing text and images with Skeleton boxes sized to match.
- An avatar becomes a circle:
h-10 w-10 rounded-full. - A title line becomes a wide bar; a subtitle becomes a shorter, thinner bar.
- Use the same container classes (padding, gap, border) as the real card.
// components/user-card-skeleton.tsx
import { Skeleton } from './skeleton';
export function UserCardSkeleton() {
return (
<div className="flex items-center gap-4 rounded-lg border p-4">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-1/3" />
<Skeleton className="h-3 w-1/2" />
</div>
</div>
);
}Lists: Repeat the Row Skeleton
For lists and tables, render a fixed count of row skeletons — enough to fill the typical viewport so the page looks complete.
- Reuse the single-row skeleton inside an array.
- Pick a count that roughly matches your usual page size (e.g. 6–8 rows).
- Keep the same
gapand wrapper as the real list to preserve spacing.
// components/user-list-skeleton.tsx
import { UserCardSkeleton } from './user-card-skeleton';
export function UserListSkeleton({ rows = 6 }: { rows?: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<UserCardSkeleton key={i} />
))}
</div>
);
}Wiring the Skeleton into loading.tsx
Now loading.tsx just composes your skeleton components inside the same outer layout as the page. The header that is static (not data-dependent) can be rendered for real even in the loading state.
- Render the real, instant parts (page title, tabs).
- Swap only the data-bound regions for skeletons.
- This gives a page that feels half-loaded already.
// app/dashboard/users/loading.tsx
import { UserListSkeleton } from '@/components/user-list-skeleton';
export default function Loading() {
return (
<section className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-bold">Users</h1>
<UserListSkeleton rows={6} />
</section>
);
}loading.tsx vs Component-Level Suspense
loading.tsx covers the whole segment — if any data on the page is slow, the entire fallback shows. Sometimes you want finer control so fast content paints first.
- Use
loading.tsxfor the segment-wide first paint. - Wrap individual slow components in
<Suspense>with their own skeleton fallback to stream them independently. - Combine both: a light segment skeleton, plus granular Suspense for the slowest widget.
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { RevenueChart } from './revenue-chart';
import { ChartSkeleton } from './chart-skeleton';
export default function Page() {
return (
<main className="p-6">
<h1 className="text-2xl font-bold">Overview</h1>
{/* Static + fast content paints immediately */}
<Suspense fallback={<ChartSkeleton />}>
{/* Slow async server component streams in later */}
<RevenueChart />
</Suspense>
</main>
);
}Pure Function for Skeleton Counts
How many skeleton rows should you show? A small helper keeps it consistent and clamps to a sane range so you never render an absurd number of placeholders.
This logic is plain TypeScript — no framework involved — so you can unit-test it in isolation.
function skeletonRowCount(pageSize: number, viewportRows = 8): number {
if (!Number.isFinite(pageSize) || pageSize <= 0) return viewportRows;
return Math.min(pageSize, viewportRows);
}
console.log(skeletonRowCount(20)); // 8 (clamped to viewport)
console.log(skeletonRowCount(3)); // 3 (fewer items than viewport)
console.log(skeletonRowCount(0)); // 8 (fallback default)
console.log(skeletonRowCount(-5)); // 8 (guards invalid input)Accessibility & Reduced Motion
Skeletons are decorative, but they still affect assistive tech and motion-sensitive users.
- Mark the loading region with
aria-busy="true"and individual placeholders witharia-hidden="true". - Provide a visually-hidden status like
<span className="sr-only">Loading users</span>for screen readers. - Respect
prefers-reduced-motionso the pulse animation does not run for users who opt out.
/* globals.css */
@media (prefers-reduced-motion: reduce) {
.animate-pulse {
animation: none;
}
}Common Pitfalls
Avoid these mistakes that defeat the purpose of a skeleton:
- Spinner-only fallback: gives no layout cue and guarantees a shift when content arrives.
- Mismatched sizes: if the skeleton card is 60px tall but the real card is 96px, the page still jumps.
- Forgetting the wrapper: different padding/grid between skeleton and page shifts everything.
- Skeleton over fast data: if a segment loads in <100ms, the flash of skeleton can feel worse — consider component-level Suspense for only the slow parts.
Quick Check
Test your understanding of route-level loading states and skeletons.
Recap
You learned how to craft meaningful loading states in the App Router:
loading.tsxis an automatic Suspense fallback for its route segment.- Build skeletons from a small
Skeletonprimitive and compose them to mirror the final layout. - Match wrappers, counts, and dimensions to avoid layout shift (CLS).
- Render instant static parts (titles, tabs) for real; skeleton only the data-bound regions.
- Use
loading.tsxfor segment-wide first paint and component-level<Suspense>to stream slow widgets independently. - Handle accessibility with
aria-busy,aria-hidden, ansr-onlystatus, andprefers-reduced-motion.
用 AI 导师学习 TypeScript — 免费
在浏览器中编写并运行真实代码,获得全天候 AI 导师的即时帮助,并在网页或应用中继续学习。
- 课程
- 22
- 课程
- 88
常见问题解答
「打造有意义的 loading.tsx 与骨架屏」课时是免费的吗?
是的 — 「打造有意义的 loading.tsx 与骨架屏」的完整文本可在网页上免费阅读。要进行交互式练习(内置代码编辑器和全天候 AI 导师)并解锁 Next.js 15 Fullstack (App Router + Server Actions) 课程的其余内容,请升级到 CoddyKit PRO。 Next.js 15 Fullstack (App Router + Server Actions) 课程共包含 4 节课。
「打造有意义的 loading.tsx 与骨架屏」这节课中我会学到什么?
构建路由级加载状态和与最终布局匹配的骨架占位符,避免页面布局偏移。 你通过在浏览器中直接运行的动手代码来练习 Next.js 15 Fullstack (App Router + Server Actions),全天候 AI 导师会在你学习这节课的过程中回答你的问题。
学习 Next.js 15 Fullstack (App Router + Server Actions) 需要有经验吗?
无需任何先前经验。CoddyKit 上的 Next.js 15 Fullstack (App Router + Server Actions) 课程适合初学者到高级学习者,你可以从这里开始或从头开始,按照自己的节奏学习。 这是第 2 节课,共 4 节。
「打造有意义的 loading.tsx 与骨架屏」课时需要多长时间?
大多数 CoddyKit 课程大约需要 5–10 分钟。每节课都很精短且互动,所以你能稳步进步,并在网页和应用中从离开的地方继续。
我能在这节 Next.js 15 Fullstack (App Router + Server Actions) 课中编写并运行代码吗?
能。每节 Next.js 15 Fullstack (App Router + Server Actions) 课都包含内置代码编辑器,你可以在浏览器中直接编写并运行真实代码,并获得即时 AI 反馈 — 无需本地设置。
此课程中的所有课时
- Suspense 边界与组件级流式传输
- 打造有意义的 loading.tsx 与骨架屏
- 部分预渲染:静态外壳与动态空洞
- 流式传输陷阱:布局偏移与瀑布流