suspense-and-loading
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseSuspense & Loading Skill: Split Data Fetching with Async Components
Suspense与加载技巧:通过异步组件拆分数据获取
You implement loading.tsx files alongside every page.tsx to provide instant UI feedback, and split data fetching into multiple async components wrapped in Suspense boundaries with skeleton fallbacks that mirror the final component design.
This skill enables progressive enhancement and granular loading states—instead of blocking the entire page on one slow query, you fetch data in parallel across multiple components, each showing its own loader while data streams in.
你可以为每个page.tsx搭配实现loading.tsx文件,以提供即时的UI反馈,并将数据获取拆分到多个异步组件中,这些组件会被包裹在Suspense边界内,同时使用骨架回退组件——该组件通过Skeleton UI模拟最终组件的设计样式。
这项技巧支持渐进式增强和细粒度加载状态——不再因单个慢查询阻塞整个页面,而是在多个组件中并行获取数据,每个组件在数据流式传输时显示自己的加载器。
When to use this skill
何时使用该技巧
Use this skill when the user asks to:
- add loading states to pages
- improve page load perception and responsiveness
- split data fetching across multiple components
- create skeleton loaders that match component layouts
- implement Suspense boundaries around async data components
- improve Core Web Vitals (especially LCP and FID)
当用户提出以下需求时,可使用该技巧:
- 为页面添加加载状态
- 提升页面加载感知体验与响应速度
- 在多个组件间拆分数据获取逻辑
- 创建与组件布局匹配的骨架加载器
- 为异步数据组件实现Suspense边界
- 优化核心Web指标(尤其是LCP和FID)
Core principles
核心原则
- Every page gets a loading.tsx: Provides instant feedback while the layout renders.
- Granular Suspense boundaries: Wrap each async data component, not the entire page.
- Async components fetch data: Server Components are async by default; fetch in the component body.
- Skeleton loaders mirror design: Use the Skeleton UI component to match final component dimensions and layout.
- Loaders in component files: Keep loading functions alongside components in the same file; make them async and reusable.
- Parallel data fetching: Use to fetch multiple data sources in parallel within a component.
Promise.all() - Deterministic rendering: Server Components render deterministically; Suspense handles async resolution.
- 每个页面都要有loading.tsx:在布局渲染时提供即时反馈。
- 细粒度Suspense边界:为每个异步数据组件单独包裹Suspense,而非整个页面。
- 异步组件负责数据获取:Server Component默认支持异步;在组件内部执行数据获取。
- 骨架加载器匹配设计样式:使用Skeleton UI组件匹配最终组件的尺寸与布局。
- 加载函数与组件同文件:将加载函数与组件放在同一文件中;设为异步且可复用。
- 并行数据获取:在组件内使用并行获取多个数据源。
Promise.all() - 确定性渲染:Server Component执行确定性渲染;Suspense处理异步解析。
File structure conventions
文件结构规范
src/app/
├─ page.tsx (async Server Component)
├─ loading.tsx (instant skeleton layout for entire page)
├─ admin/
│ ├─ page.tsx (async Server Component)
│ ├─ loading.tsx (skeleton for admin page)
│ └─ _components/
│ ├─ user-list.tsx (async component with loading function inside)
│ ├─ dashboard-stats.tsx (async component with loading function inside)
│ └─ user-form.tsx (client component, no loading needed)
└─ users/
├─ page.tsx
├─ loading.tsx
└─ _components/
└─ user-detail.tsxKey conventions:
- matches folder structure exactly with same route.
loading.tsx - Private components in keep loading skeletons inline.
_components/ - File-local loading functions are async, reusable, and match component shape.
src/app/
├─ page.tsx (异步Server Component)
├─ loading.tsx (整个页面的即时骨架布局)
├─ admin/
│ ├─ page.tsx (异步Server Component)
│ ├─ loading.tsx (admin页面的骨架布局)
│ └─ _components/
│ ├─ user-list.tsx (内部包含加载函数的异步组件)
│ ├─ dashboard-stats.tsx (内部包含加载函数的异步组件)
│ └─ user-form.tsx (客户端组件,无需加载状态)
└─ users/
├─ page.tsx
├─ loading.tsx
└─ _components/
└─ user-detail.tsx关键规范:
- 的文件夹结构与对应路由完全匹配。
loading.tsx - 中的私有组件将骨架加载器内联放置。
_components/ - 文件内的加载函数为异步、可复用,且返回数据格式与组件预期一致。
Hard rules
硬性规则
-
loading.tsx structure
- Must be a default export of a React component (no "use client").
- Returns skeleton UI that matches the page layout exactly.
- Lives in the same directory as the it serves.
page.tsx - Never imports data or server actions (skeleton only).
-
Async components
- Can be Server Components (function) or inside
async.page.tsx - Fetch data in the component body (top-level, not in renders/effects).
- Wrap the component in when used in
<Suspense>.page.tsx - Always have a prop pointing to a skeleton loader.
fallback
- Can be Server Components (
-
Loading functions
- Location: defined in the same file as the component (near the component or above it).
- Async: marked to handle Promise resolution.
async - Reusable: exported if used in multiple places; otherwise private.
- Naming: or
loadXxx()(verb-based, clear intent).fetchXxx() - Returns: the exact data type the component expects.
- No side effects: only query/compute; never mutate state outside the function.
-
Skeleton loaders
- Use shadcn/ui component.
Skeleton - Mirror the component's layout exactly (same grid, spacing, element count).
- Return JSX that fills the space; users should not see layout shift.
- Animate subtly (Skeleton has a built-in shimmer effect).
- Use shadcn/ui
-
Suspense boundaries
- Wrap async components in .
<Suspense fallback={<SkeletonXxx />}> - Each boundary should cover ONE logical data fetch unit.
- Never nest deeply; keep boundaries at the page level or top of component tree.
- Do not use Suspense for client-side state or effects (only server data).
- Wrap async components in
-
Error handling
- Async components that throw errors are caught by Error Boundaries (implement a separate ).
error.tsx - Do not catch errors inside async components; let them propagate.
- For individual fallible operations, wrap in try-catch in the loading function.
- Async components that throw errors are caught by Error Boundaries (implement a separate
-
loading.tsx结构
- 必须是React组件的默认导出(无需添加"use client")。
- 返回与页面布局完全匹配的骨架UI。
- 与所服务的位于同一目录下。
page.tsx - 绝不能导入数据或服务端操作(仅包含骨架内容)。
-
异步组件
- 可以是Server Component(异步函数)或嵌套在中。
page.tsx - 在组件内部(顶层,而非渲染/副作用函数中)执行数据获取。
- 在中使用时,必须用
page.tsx包裹。<Suspense> - 必须设置属性指向骨架加载器。
fallback
- 可以是Server Component(异步函数)或嵌套在
-
加载函数
- 位置:定义在组件所在的同一文件中(靠近组件或在其上方)。
- 异步:标记为以处理Promise解析。
async - 可复用:若在多个地方使用则导出;否则设为私有。
- 命名:使用或
loadXxx()(动词开头,意图明确)。fetchXxx() - 返回值:与组件预期的 exact 数据类型一致。
- 无副作用:仅执行查询/计算;绝不修改函数外部的状态。
-
骨架加载器
- 使用shadcn/ui的组件。
Skeleton - 与组件布局完全匹配(相同的网格、间距、元素数量)。
- 返回填充对应空间的JSX;避免用户看到布局偏移。
- 包含微妙的动画效果(Skeleton内置闪光动画)。
- 使用shadcn/ui的
-
Suspense边界
- 用包裹异步组件。
<Suspense fallback={<SkeletonXxx />}> - 每个边界应覆盖一个逻辑数据获取单元。
- 避免深层嵌套;将边界放在页面层级或组件树顶层。
- 不要将Suspense用于客户端状态或副作用(仅用于服务端数据)。
- 用
-
错误处理
- 抛出错误的异步组件会被错误边界捕获(需单独实现)。
error.tsx - 不要在异步组件内部捕获错误;让错误向上传播。
- 对于单个可能失败的操作,在加载函数中使用try-catch包裹。
- 抛出错误的异步组件会被错误边界捕获(需单独实现
Canonical pattern: Async component + loading function + Suspense boundary
标准模式:异步组件 + 加载函数 + Suspense边界
Step 1: Define the loading function in the component file
步骤1:在组件文件中定义加载函数
ts
// src/app/admin/_components/user-list.tsx
"use server";
import { Skeleton } from "~/components/ui/skeleton";
import { getUsers } from "~/services";
// Skeleton loader that mirrors UserList layout
export async function UserListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
);
}
// Async Server Component
export default async function UserList({ page = 1, pageSize = 10 }) {
const { items, totalPages } = await getUsers(page, pageSize);
return (
<div className="space-y-4">
{items.map((user) => (
<div key={user.id} className="flex gap-4 rounded border p-4">
<Avatar src={user.avatar} alt={user.name} />
<div>
<p className="font-semibold">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
))}
<Pagination total={totalPages} current={page} />
</div>
);
}ts
// src/app/admin/_components/user-list.tsx
"use server";
import { Skeleton } from "~/components/ui/skeleton";
import { getUsers } from "~/services";
// 匹配UserList布局的骨架加载器
export async function UserListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
);
}
// 异步Server Component
export default async function UserList({ page = 1, pageSize = 10 }) {
const { items, totalPages } = await getUsers(page, pageSize);
return (
<div className="space-y-4">
{items.map((user) => (
<div key={user.id} className="flex gap-4 rounded border p-4">
<Avatar src={user.avatar} alt={user.name} />
<div>
<p className="font-semibold">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</div>
))}
<Pagination total={totalPages} current={page} />
</div>
);
}Step 2: Wrap the async component in Suspense on the page
步骤2:在页面中用Suspense包裹异步组件
ts
// src/app/admin/page.tsx
import { Suspense } from "react";
import { UserList, UserListSkeleton } from "./_components/user-list";
import { DashboardStats, DashboardStatsSkeleton } from "./_components/dashboard-stats";
export default async function AdminPage() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
{/* Suspense boundary for stats */}
<Suspense fallback={<DashboardStatsSkeleton />}>
<DashboardStats />
</Suspense>
{/* Suspense boundary for user list */}
<Suspense fallback={<UserListSkeleton />}>
<UserList page={1} pageSize={10} />
</Suspense>
</div>
);
}ts
// src/app/admin/page.tsx
import { Suspense } from "react";
import { UserList, UserListSkeleton } from "./_components/user-list";
import { DashboardStats, DashboardStatsSkeleton } from "./_components/dashboard-stats";
export default async function AdminPage() {
return (
<div className="space-y-8">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
{/* 统计数据的Suspense边界 */}
<Suspense fallback={<DashboardStatsSkeleton />}>
<DashboardStats />
</Suspense>
{/* 用户列表的Suspense边界 */}
<Suspense fallback={<UserListSkeleton />}>
<UserList page={1} pageSize={10} />
</Suspense>
</div>
);
}Step 3: Create a loading.tsx for the route
步骤3:为路由创建loading.tsx
ts
// src/app/admin/loading.tsx
import { Skeleton } from "~/components/ui/skeleton";
export default async function AdminLoading() {
return (
<div className="space-y-8 p-6">
{/* Keep static text in loading animation */}
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
{/* Stats skeleton */}
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded border p-4">
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* User list skeleton */}
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
</div>
);
}ts
// src/app/admin/loading.tsx
import { Skeleton } from "~/components/ui/skeleton";
export default async function AdminLoading() {
return (
<div className="space-y-8 p-6">
{/* 在加载动画中保留静态文本 */}
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
{/* 统计数据骨架 */}
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded border p-4">
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-8 w-16" />
</div>
))}
</div>
{/* 用户列表骨架 */}
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
</div>
);
}Multi-component async pattern: Parallel data fetching
多组件异步模式:并行数据获取
When a component needs multiple data sources, fetch them in parallel:
ts
// src/app/dashboard/_components/dashboard-stats.tsx
import { Skeleton } from "~/components/ui/skeleton";
import { getStats, getRecentActivity, getCharts } from "~/services";
async function loadDashboard() {
// Fetch all data in parallel
const [stats, activity, charts] = await Promise.all([
getStats(),
getRecentActivity(),
getCharts(),
]);
return { stats, activity, charts };
}
export async function DashboardStatsSkeleton() {
return (
<div className="space-y-6">
{/* Stats grid skeleton */}
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border p-6">
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
{/* Activity skeleton */}
<div className="rounded-lg border p-6">
<Skeleton className="mb-4 h-6 w-32" />
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
{/* Chart skeleton */}
<div className="rounded-lg border p-6">
<Skeleton className="mb-4 h-6 w-32" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
export async function DashboardStats() {
const { stats, activity, charts } = await loadDashboard();
return (
<div className="space-y-6">
{/* Stats section */}
<div className="grid grid-cols-3 gap-4">
{stats.map((stat) => (
<Card key={stat.id}>
<CardHeader>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-xs text-green-600">+{stat.change}%</p>
</CardContent>
</Card>
))}
</div>
{/* Activity section */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{activity.map((item) => (
<ActivityItem key={item.id} item={item} />
))}
</CardContent>
</Card>
{/* Charts section */}
<Card>
<CardHeader>
<CardTitle>Analytics</CardTitle>
</CardHeader>
<CardContent>
<AnalyticsChart data={charts} />
</CardContent>
</Card>
</div>
);
}当组件需要多个数据源时,可并行获取:
ts
// src/app/dashboard/_components/dashboard-stats.tsx
import { Skeleton } from "~/components/ui/skeleton";
import { getStats, getRecentActivity, getCharts } from "~/services";
async function loadDashboard() {
// 并行获取所有数据
const [stats, activity, charts] = await Promise.all([
getStats(),
getRecentActivity(),
getCharts(),
]);
return { stats, activity, charts };
}
export async function DashboardStatsSkeleton() {
return (
<div className="space-y-6">
{/* 统计数据网格骨架 */}
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border p-6">
<Skeleton className="mb-2 h-4 w-24" />
<Skeleton className="h-8 w-20" />
</div>
))}
</div>
{/* 活动记录骨架 */}
<div className="rounded-lg border p-6">
<Skeleton className="mb-4 h-6 w-32" />
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
</div>
{/* 图表骨架 */}
<div className="rounded-lg border p-6">
<Skeleton className="mb-4 h-6 w-32" />
<Skeleton className="h-64 w-full" />
</div>
</div>
);
}
export async function DashboardStats() {
const { stats, activity, charts } = await loadDashboard();
return (
<div className="space-y-6">
{/* 统计数据区域 */}
<div className="grid grid-cols-3 gap-4">
{stats.map((stat) => (
<Card key={stat.id}>
<CardHeader>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stat.value}</p>
<p className="text-xs text-green-600">+{stat.change}%</p>
</CardContent>
</Card>
))}
</div>
{/* 活动记录区域 */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
{activity.map((item) => (
<ActivityItem key={item.id} item={item} />
))}
</CardContent>
</Card>
{/* 图表区域 */}
<Card>
<CardHeader>
<CardTitle>Analytics</CardTitle>
</CardHeader>
<CardContent>
<AnalyticsChart data={charts} />
</CardContent>
</Card>
</div>
);
}Conditional async components and Suspense
条件渲染异步组件与Suspense
For components that only render conditionally, wrap in Suspense at the point of use:
ts
// src/app/users/page.tsx
import { Suspense } from "react";
import { UserDetail, UserDetailSkeleton } from "./_components/user-detail";
export default async function UsersPage({ searchParams }) {
const { id } = searchParams;
return (
<div>
<h1>Users</h1>
{/* Only render detail if ID is provided */}
{id && (
<Suspense fallback={<UserDetailSkeleton />}>
<UserDetail userId={id} />
</Suspense>
)}
</div>
);
}对于仅在特定条件下渲染的组件,在使用位置包裹Suspense:
ts
// src/app/users/page.tsx
import { Suspense } from "react";
import { UserDetail, UserDetailSkeleton } from "./_components/user-detail";
export default async function UsersPage({ searchParams }) {
const { id } = searchParams;
return (
<div>
<h1>Users</h1>
{/* 仅当提供ID时渲染详情 */}
{id && (
<Suspense fallback={<UserDetailSkeleton />}>
<UserDetail userId={id} />
</Suspense>
)}
</div>
);
}Error boundaries alongside loading states
加载状态搭配错误边界
Create an in the same directory as to handle async errors:
error.tsxloading.tsxts
// src/app/admin/error.tsx
"use client";
import { useEffect } from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Admin page error:", error);
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="max-w-md space-y-4 text-center">
<AlertCircle className="mx-auto h-12 w-12 text-red-500" />
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
</div>
);
}在所在目录创建以处理异步错误:
loading.tsxerror.tsxts
// src/app/admin/error.tsx
"use client";
import { useEffect } from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "~/components/ui/button";
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error("Admin page error:", error);
}, [error]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="max-w-md space-y-4 text-center">
<AlertCircle className="mx-auto h-12 w-12 text-red-500" />
<h2 className="text-xl font-semibold">Something went wrong</h2>
<p className="text-sm text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Try again</Button>
</div>
</div>
);
}Best practices
最佳实践
-
Skeleton granularity: One skeleton per Suspense boundary. Don't create one huge skeleton for the entire page if you have multiple Suspense regions.
-
Avoid overfetching in loading functions: The loading function should be as efficient as the regular component—same database queries, same selections.
-
Keep loading.tsx minimal: It should be static HTML with Skeleton components. No dynamic content, no server functions.
-
Suspense placement matters: Place boundaries around the slowest async operation, not around the entire page. This allows faster sections to render immediately.
-
Naming clarity: Useor
load*prefixes for async functions. Usefetch*for fallback components. Makes code scannable.*Skeleton -
Reuse loading functions: If multiple Suspense boundaries render the same component, they can share the same loading function.
-
Test progressive enhancement: Turn on network throttling in DevTools to verify that loading states appear before data arrives.
-
骨架粒度:每个Suspense边界对应一个骨架。如果有多个Suspense区域,不要为整个页面创建一个超大的骨架。
-
避免加载函数过度获取:加载函数应与常规组件一样高效——使用相同的数据库查询和字段选择。
-
保持loading.tsx简洁:仅包含静态HTML和Skeleton组件。无动态内容,无服务端函数。
-
Suspense位置很重要:将边界放在最慢的异步操作周围,而非整个页面。这样可以让更快的区域立即渲染。
-
命名清晰:异步函数使用或
load*前缀。回退组件使用fetch*后缀。提升代码可读性。*Skeleton -
复用加载函数:如果多个Suspense边界渲染相同组件,可共享同一个加载函数。
-
测试渐进式增强:在开发者工具中开启网络节流,验证加载状态在数据到达前是否正常显示。
Common mistakes to avoid
常见错误规避
❌ Mistake 1: Putting the entire page in one Suspense boundary
ts
// BAD
export default async function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header /> {/* Blocks on slow query */}
<Sidebar /> {/* Blocks on slow query */}
<SlowDataComponent />
</Suspense>
);
}✅ Better: Granular boundaries
ts
// GOOD
export default async function Page() {
return (
<>
<Header />
<Sidebar />
<Suspense fallback={<SlowDataSkeleton />}>
<SlowDataComponent />
</Suspense>
</>
);
}❌ Mistake 2: Loading logic in loading.tsx
ts
// BAD - loading.tsx is calling getUsers!
export default async function AdminLoading() {
const users = await getUsers(); // 🚨 This is async, defeats purpose
return <div>{users.length} users loading...</div>;
}✅ Correct: loading.tsx is static
ts
// GOOD - Just a skeleton, no logic
export default async function AdminLoading() {
return (
<div>
<Skeleton className="h-10 w-64 mb-4" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
</div>
);
}❌ Mistake 3: Skeleton that doesn't match component size
ts
// BAD - Skeleton is too small, causes layout shift
export async function UserListSkeleton() {
return <Skeleton className="h-4 w-20" />; // Way too small!
}✅ Correct: Skeleton matches final dimensions
ts
// GOOD - Matches UserList exactly
export async function UserListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
);
}❌ 错误1:将整个页面放在一个Suspense边界中
ts
// 错误示例
export default async function Page() {
return (
<Suspense fallback={<PageSkeleton />}>
<Header /> {/* 被慢查询阻塞 */}
<Sidebar /> {/* 被慢查询阻塞 */}
<SlowDataComponent />
</Suspense>
);
}✅ 优化方案:使用细粒度边界
ts
// 正确示例
export default async function Page() {
return (
<>
<Header />
<Sidebar />
<Suspense fallback={<SlowDataSkeleton />}>
<SlowDataComponent />
</Suspense>
</>
);
}❌ 错误2:在loading.tsx中加入加载逻辑
ts
// 错误示例 - loading.tsx调用了getUsers!
export default async function AdminLoading() {
const users = await getUsers(); // 🚨 这是异步操作,违背了设计初衷
return <div>{users.length} users loading...</div>;
}✅ 正确做法:loading.tsx为静态内容
ts
// 正确示例 - 仅包含骨架,无逻辑
export default async function AdminLoading() {
return (
<div>
<Skeleton className="h-10 w-64 mb-4" />
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
</div>
);
}❌ 错误3:骨架与组件尺寸不匹配
ts
// 错误示例 - 骨架过小,导致布局偏移
export async function UserListSkeleton() {
return <Skeleton className="h-4 w-20" />; // 尺寸太小!
}✅ 正确做法:骨架与最终组件尺寸完全匹配
ts
// 正确示例 - 与UserList完全匹配
export async function UserListSkeleton() {
return (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4 rounded border p-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-full" />
</div>
</div>
))}
</div>
);
}