Next.js Loading 状态管理:loading.tsx 与 Suspense 实战指南
你可能遇到过这种情况:用户点击了一个链接,然后页面白屏了整整3秒,什么反馈都没有。用户心里开始打鼓:“是不是卡住了?“然后疯狂按F5刷新,结果页面刚加载好又被刷没了…
说实话,我之前也是这样。每次写新页面都要在组件里加上:
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
useEffect(() => {
setLoading(true);
fetchData()
.then(setData)
.finally(() => setLoading(false));
}, []);
if (loading) return <Spinner />;
代码又臭又长,而且每个页面都得写一遍。更糟的是,团队里每个人写的loading逻辑都不太一样,有人用全局状态,有人用Context,维护起来简直是噩梦。
直到有一天,我在看Next.js官方文档的时候发现:其实Next.js早就内置了一套更优雅的loading管理方案——loading.tsx 和 Suspense。
用了之后我才发现,原来管理loading状态可以这么简单。代码量少了一半不说,用户体验还提升了一个档次。这篇文章我就来分享一下这套方案的实战经验。
为什么要用 loading.tsx 和 Suspense
传统方案的痛点
我先给你看一个真实的例子。假设我们要做一个博客列表页,传统写法大概是这样:
// app/blog/page.tsx
'use client';
import { useState, useEffect } from 'react';
export default function BlogPage() {
const [loading, setLoading] = useState(true);
const [posts, setPosts] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/posts')
.then(res => res.json())
.then(data => {
setPosts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) {
return <div className="spinner">Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
看起来还行对吧?但问题在于:
- 代码冗余:每个页面都要写这一坨状态管理代码
- 状态割裂:loading、data、error分散在三个state里,容易出现状态不同步的bug
- 必须是Client Component:用了useState和useEffect,整个组件只能跑在客户端,失去了服务端渲染的优势
- 用户体验差:页面从点击到显示loading,中间有个明显的白屏卡顿
而且你要是做过Code Review就知道,不同开发者写的loading处理千奇百怪。有人把loading状态提到Context里,有人用Zustand全局管理,有人直接在每个组件里各写各的。项目大了之后简直维护不动。
Next.js 的解决方案
Next.js的App Router给了我们三个核心特性来解决这些问题:
1. loading.tsx - 约定优于配置
你只需要在路由文件夹里创建一个 loading.tsx 文件,Next.js会自动把它作为这个路由的loading状态UI。不用手写useState,不用管理状态,连Suspense都不用自己包。
2. Suspense - React 18原生支持
React 18的Suspense可以让你在组件级别精细控制loading状态。哪部分数据慢就给哪部分加个Suspense边界,其他部分该显示显示,不用全页面等待。
3. Streaming - 边加载边显示
配合Next.js的流式渲染,页面可以一部分一部分地显示出来。头部先出来,然后是侧边栏,最后才是慢的数据部分。用户不用傻等白屏,体验好太多了。
而且还有个数据你可能感兴趣:用上骨架屏和Streaming之后,可以明显降低FCP(First Contentful Paint)和LCP(Largest Contentful Paint)时间,Google PageSpeed Insights的分数能提升好几分。
loading.tsx 基础用法
快速上手:第一个 loading.tsx
好,废话不多说,我们直接上手写一个最简单的 loading.tsx。
假设你有这样的目录结构:
app/
blog/
page.tsx
你只需要在 blog 文件夹里加一个 loading.tsx:
app/
blog/
loading.tsx ← 新加的
page.tsx
然后 loading.tsx 里写个简单的loading UI:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
<p className="ml-4">加载中...</p>
</div>
);
}
就这么简单,10行代码搞定。现在当用户访问 /blog 的时候,在 page.tsx 加载完成之前,Next.js会自动显示这个loading组件。
重点来了:你完全不需要手动包Suspense,Next.js会自动帮你做。实际渲染的时候,它会把你的 page.tsx 包在一个 <Suspense fallback={<Loading />}> 里。
我第一次看到这个的时候还挺困惑的:“这也太简单了吧,真的能用吗?“然后试了一下,真的可以。而且代码清爽了好多,不用再在每个页面里写那一堆useState了。
loading.tsx 的作用范围
loading.tsx 有个很重要的概念叫做路由段(Route Segment)。简单说就是:它会作用于同一文件夹下的 page.tsx 和所有子路由。
举个例子:
app/
blog/
loading.tsx ← 会作用于 /blog 和 /blog/[id]
page.tsx ← /blog 列表页
[id]/
page.tsx ← /blog/123 详情页
这个 loading.tsx 会在以下情况显示:
- 用户访问
/blog(列表页加载时) - 用户从列表点进
/blog/123(详情页加载时)
但是!它不会影响layout。如果你的 blog/layout.tsx 里有个导航栏,那导航栏会一直保持显示,只有 page.tsx 的部分会被loading替换。
这就是Next.js官方文档说的”共享布局保持交互式”的意思。用户在等待新页面加载的时候,还能点导航栏切换到别的页面,不会整个界面都卡死。
这里有个可视化图解会更清楚:
Layout(一直显示)
├─ 导航栏
└─ Suspense Boundary
├─ Loading UI(数据加载时显示)
└─ Page(数据加载完显示)
Server Component vs Client Component
loading.tsx 默认是一个 Server Component。大多数情况下这就够了,你直接返回一些JSX就行。
但有时候你想加点动画效果,比如用Framer Motion做个淡入淡出,或者用一些需要客户端JavaScript的库,那就得加上 'use client':
// app/blog/loading.tsx
'use client';
import { motion } from 'framer-motion';
export default function Loading() {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex items-center justify-center min-h-screen"
>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900"></div>
</motion.div>
);
}
我一般的原则是:能用Server Component就用Server Component,除非真的需要客户端交互才加 'use client'。毕竟Server Component不用打包到客户端bundle里,页面加载更快。
骨架屏实战
为什么骨架屏比 spinner 更好
你肯定见过那种转圈圈的loading spinner对吧。其实从用户体验角度来说,骨架屏(Skeleton Screen)比spinner好太多了。
为什么呢?有个用户心理学的研究发现:当用户看到骨架屏的时候,大脑会自动预期”内容马上就来了”,感知上的等待时间会更短。而看到spinner的时候,用户只知道”在加载”,但不知道加载的是啥,也不知道要等多久,焦虑感更强。
而且骨架屏还有个好处:它可以提前告诉用户页面的大概布局是什么样的。比如用户看到三个横条的骨架,就知道这里会有三篇文章。心里有个预期,就不会觉得那么慌。
三种实现方案
骨架屏的实现方式有很多,我这里介绍三种最常用的,你可以根据项目情况选择:
方案1:纯CSS实现(最轻量)
如果你的项目不想引入额外依赖,纯CSS就能搞定:
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8 animate-pulse">
{/* 标题骨架 */}
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
{/* 摘要骨架 */}
<div className="space-y-2">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
{/* 元信息骨架 */}
<div className="flex gap-4 mt-4">
<div className="h-3 bg-gray-200 rounded w-20"></div>
<div className="h-3 bg-gray-200 rounded w-24"></div>
</div>
</div>
))}
</div>
);
}
优点是零依赖,性能最好。缺点是要自己写样式,稍微麻烦点。
方案2:react-loading-skeleton 库(最快速)
如果你想快速搞定,不想写太多样式,可以用 react-loading-skeleton:
npm install react-loading-skeleton
// app/blog/loading.tsx
'use client';
import Skeleton from 'react-loading-skeleton';
import 'react-loading-skeleton/dist/skeleton.css';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton height={32} width="75%" className="mb-4" />
<Skeleton count={2} />
<div className="flex gap-4 mt-4">
<Skeleton width={80} />
<Skeleton width={100} />
</div>
</div>
))}
</div>
);
}
这个库用起来很方便,而且动画效果做得挺好的。我在小项目里经常用这个。
方案3:shadcn/ui(最专业)
如果你的项目本来就在用shadcn/ui,那直接用它的Skeleton组件最方便:
npx shadcn-ui@latest add skeleton
// app/blog/loading.tsx
import { Skeleton } from '@/components/ui/skeleton';
export default function Loading() {
return (
<div className="max-w-4xl mx-auto p-6">
{[1, 2, 3].map((i) => (
<div key={i} className="mb-8">
<Skeleton className="h-8 w-3/4 mb-4" />
<Skeleton className="h-4 w-full mb-2" />
<Skeleton className="h-4 w-5/6 mb-4" />
<div className="flex gap-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
</div>
</div>
))}
</div>
);
}
这个的好处是样式跟你的设计系统完全一致,不用额外调样式。
骨架屏的设计原则
不管用哪种方案,有几个设计原则要注意:
-
匹配实际布局:骨架屏的结构要跟真实内容的布局一致。比如文章列表有标题、摘要、标签,那骨架也要有对应的三块区域。
-
subtle animation:动画不要太夸张,淡淡的闪烁就好。太花哨的动画会分散用户注意力,反而让等待感更强。
-
合理的数量:一般显示3-5个骨架条目就够了,不用铺满整个屏幕。太多反而显得累赘。
真实案例:博客列表页完整实现
好,现在我们把前面的知识点串起来,做一个完整的博客列表页。
首先是 loading.tsx:
// app/blog/loading.tsx
export default function BlogLoading() {
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<div className="h-12 bg-gray-200 rounded w-1/3 mb-8 animate-pulse"></div>
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<article key={i} className="border-b pb-8 animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-3"></div>
<div className="space-y-2 mb-4">
<div className="h-4 bg-gray-200 rounded"></div>
<div className="h-4 bg-gray-200 rounded w-11/12"></div>
<div className="h-4 bg-gray-200 rounded w-4/5"></div>
</div>
<div className="flex gap-3">
<div className="h-6 bg-gray-200 rounded-full w-16"></div>
<div className="h-6 bg-gray-200 rounded-full w-20"></div>
</div>
</article>
))}
</div>
</div>
);
}
然后是实际的 page.tsx(用Server Component):
// app/blog/page.tsx
async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
cache: 'no-store' // 确保每次都重新获取
});
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export default async function BlogPage() {
const posts = await getPosts();
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-4xl font-bold mb-8">博客文章</h1>
<div className="space-y-8">
{posts.map((post) => (
<article key={post.id} className="border-b pb-8">
<h2 className="text-2xl font-semibold mb-3">
<a href={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</a>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="flex gap-3">
{post.tags.map((tag) => (
<span key={tag} className="px-3 py-1 bg-gray-100 rounded-full text-sm">
{tag}
</span>
))}
</div>
</article>
))}
</div>
</div>
);
}
看到没?page.tsx 变成了一个 async 函数,直接在组件里 await 数据。不用useState,不用useEffect,代码清爽多了。
而且因为是Server Component,这些代码都在服务端跑,不会增加客户端bundle大小。首屏加载更快。
调试技巧:用 React DevTools 测试
开发的时候你可能想测试一下loading效果,但数据加载太快了,loading一闪而过根本看不清。
这里有个技巧:用 React DevTools 手动切换Suspense边界。
- 安装 React DevTools 浏览器插件
- 打开开发者工具,切到 Components 标签
- 找到
<Suspense>组件 - 右键点击,选择 “Suspend this Suspense boundary”
这样loading UI就会一直显示,你就可以慢慢调样式了。调好了再取消suspend就行。
老实讲,这个功能我是在踩了好几次坑之后才发现的。早知道有这个,能省好多时间。
Suspense 进阶技巧
手动设置 Suspense 边界
loading.tsx 很方便,但有时候你需要更精细的控制。比如页面上有多个独立的数据源,你想让它们分别显示loading,而不是等全部数据都好了才一起显示。
这时候就需要手动设置Suspense边界了。
先说个常见错误:很多人(包括我最初)会把Suspense放在数据获取组件的内部,像这样:
// ❌ 错误示例 - Suspense放得太低了
async function PostList() {
const posts = await fetchPosts();
return (
<Suspense fallback={<Loading />}> {/* 这样不work! */}
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
</Suspense>
);
}
这样是不会生效的。为啥?因为Suspense需要在组件树的更高位置,才能”捕获”到下面组件的异步操作。
正确的做法是把Suspense放在父组件:
// ✅ 正确示例 - Suspense在父组件
export default function BlogPage() {
return (
<div>
<h1>博客文章</h1>
<Suspense fallback={<PostListSkeleton />}>
<PostList />
</Suspense>
</div>
);
}
// 子组件做数据获取
async function PostList() {
const posts = await fetchPosts();
return (
<div>
{posts.map(post => <Post key={post.id} {...post} />)}
</div>
);
}
你可以把Suspense想象成一道闸门。它站在组件树的某个位置,监控下面的所有异步操作。只要下面有组件在等数据,闸门就关上,显示fallback。数据都到了,闸门才打开,显示真实内容。
动态路由的特殊处理
这个坑我踩得很深,必须重点说一下。
假设你有个产品详情页 /products/[id],用户从产品A(id=1)切换到产品B(id=2)。你会发现:loading.tsx不显示了!
页面内容直接从产品A变成产品B,中间没有任何loading过渡,体验很突兀。
这是因为React有个优化机制:如果组件类型一样(都是ProductPage),它会复用这个组件实例,只更新props。所以Suspense以为”组件没变啊,不用重新suspend”。
解决方案:给Suspense加个 key 属性,告诉React”这是个新组件,要重新渲染”。
// app/products/[id]/page.tsx
import { Suspense } from 'react';
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<Suspense key={params.id} fallback={<ProductSkeleton />}>
<ProductDetail id={params.id} />
</Suspense>
);
}
async function ProductDetail({ id }: { id: string }) {
const product = await fetchProduct(id);
return (
<div>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>${product.price}</span>
</div>
);
}
注意这行:<Suspense key={params.id} ...>
现在当id变化的时候,React会销毁旧的Suspense实例,创建新的。新实例会重新进入suspend状态,loading UI就会正常显示了。
我当时在这个问题上卡了半天,最后在GitHub issue里看到有人提到key这个trick,试了一下立马就好了。有时候解决方案就是这么简单,但你不知道就是不知道。
多个加载状态的协调
最后说个稍微复杂点的场景:页面同时加载多个数据源。
比如一个仪表盘页面,有用户信息、统计数据、最近活动三部分,每部分都要调API。你有两种策略:
策略1:全部加载完再显示(一个Suspense包全部)
export default function Dashboard() {
return (
<Suspense fallback={<DashboardSkeleton />}>
<UserInfo /> {/* 调API 1 */}
<Statistics /> {/* 调API 2 */}
<RecentActivity /> {/* 调API 3 */}
</Suspense>
);
}
优点:实现简单,一次性显示完整内容
缺点:最慢的那个API拖累整体,用户等待时间 = 最慢API的时间
策略2:增量显示(多个Suspense边界)
export default function Dashboard() {
return (
<div>
<Suspense fallback={<UserInfoSkeleton />}>
<UserInfo />
</Suspense>
<Suspense fallback={<StatsSkeleton />}>
<Statistics />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
);
}
优点:哪部分快哪部分先出来,用户感知的等待时间更短
缺点:页面会”跳”,布局会随着内容加载不断变化,有时候挺晃眼
我自己的选择是:看数据的重要程度。
- 核心数据(比如用户信息)用一个Suspense,确保一起显示
- 次要数据(比如推荐内容、广告)单独用Suspense,让它们异步加载
这样既保证了核心体验,又不会让用户傻等所有数据。
常见问题和解决方案
Suspense 不生效怎么办
如果你发现Suspense不work,检查这几点:
1. 数据获取方式对不对
Suspense只对”兼容Suspense的数据获取方式”有效。在Next.js App Router里,这意味着:
- ✅ Server Component里直接await(推荐)
- ✅ 用支持Suspense的库(如SWR、React Query)
- ❌ useEffect里fetch(不支持)
- ❌ 传统的Promise.then(不支持)
2. 组件位置对不对
Suspense要放在获取数据的组件的上层,不能放在同一组件或下层。
3. 版本兼容性
确保你用的是:
- React 18+
- Next.js 13+(App Router)
4. 调试方法
用React DevTools手动切换Suspense边界。如果手动切换都没反应,说明Suspense压根没生效,检查前面几点。
useFormStatus hook 的坑
如果你在用Server Actions做表单提交,可能会用到 useFormStatus 这个hook来显示提交状态。
这里有个坑:useFormStatus 只在 Client Component 里工作。
但是!form本身得在Server Component里渲染,否则Server Action没法绑定。
所以正确的做法是:Server Component 渲染 form,Client Component 显示状态。
// app/actions.ts
'use server';
export async function submitForm(formData: FormData) {
// 处理表单...
await saveToDatabase(formData);
}
// app/page.tsx (Server Component)
import { submitForm } from './actions';
import { SubmitButton } from './submit-button';
export default function Page() {
return (
<form action={submitForm}>
<input name="email" type="email" />
<SubmitButton />
</form>
);
}
// app/submit-button.tsx (Client Component)
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? '提交中...' : '提交'}
</button>
);
}
注意form在Server Component里,button在Client Component里。这样Server Action和loading状态就都能正常工作了。
预取(Prefetch)对 loading 的影响
Next.js的 <Link> 组件默认会预取链接的页面(当链接出现在视口里的时候)。
这导致有时候你点链接,loading一闪而过,甚至根本没显示。这是因为数据已经预取好了,不需要loading了。
如果你想测试loading效果,可以暂时关掉预取:
<Link href="/blog" prefetch={false}>
博客
</Link>
但生产环境还是建议开着预取,用户体验更好。如果担心loading显示时间太短用户注意不到,可以给loading加个最小显示时间(比如300ms),或者用骨架屏代替spinner。
总结
好了,咱们来快速回顾一下核心要点:
-
loading.tsx是路由级loading的最佳实践:文件放在路由文件夹里,Next.js自动处理一切。告别手写useState,代码清爽一半。
-
骨架屏比spinner体验更好:提前展示布局,降低用户焦虑。用纯CSS、react-loading-skeleton或UI库实现都行,看项目需求。
-
Suspense要放在组件树上层:它是个闸门,监控下面所有异步操作。放错位置就不生效。
-
动态路由记得加key:否则切换ID时loading不显示。
<Suspense key={params.id}>这一行别忘了。 -
多数据源按需拆分Suspense:核心数据一起显示,次要数据异步加载,平衡体验和性能。
说实话,从手写useState到用loading.tsx,这不是增加工作量,而是更聪明地工作。代码少了,bug少了,用户体验还好了,何乐而不为呢?
下一步行动
如果你现在就想试试,我建议你:
立即行动:找一个现有项目,挑一个简单的列表页,把loading改成loading.tsx。亲手试一次,比看十篇文章都有用。
进阶学习:loading搞定之后,下一步可以研究Error Boundaries。它跟loading是一套的,一个管加载状态,一个管错误状态。我后面会写一篇Error Boundaries的实战文章,到时候咱们接着聊。
分享经验:你在项目里是怎么处理loading的?用了什么方案?踩过什么坑?欢迎在评论区分享,大家一起交流。
参考资料:
Next.js Loading 状态管理完整流程
使用loading.tsx和Suspense实现专业级加载体验,告别手写useState
⏱️ 预计耗时: 1 小时
- 1
步骤1: 创建 loading.tsx 文件
在路由目录创建loading.tsx:
• app/dashboard/loading.tsx:dashboard路由的加载状态
• app/products/[id]/loading.tsx:动态路由的加载状态
文件内容:
export default function Loading() {
return <div>加载中...</div>
}
Next.js会自动在页面加载时显示这个组件 - 2
步骤2: 实现骨架屏
创建更专业的加载UI:
• 使用Skeleton组件模拟内容布局
• 保持与实际内容相似的布局
• 使用动画提升体验
示例:
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-4 bg-gray-200 rounded w-full mb-2"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
</div>
)
} - 3
步骤3: 使用 Suspense 包裹异步组件
在组件中使用Suspense:
• 包裹异步数据获取的组件
• 设置fallback显示加载状态
• 支持嵌套Suspense实现细粒度加载
示例:
<Suspense fallback={<Loading />}>
<AsyncComponent />
</Suspense>
多个组件可以分别用Suspense包裹:
• 每个组件独立加载
• 快的先显示,慢的后显示
• 提升用户体验 - 4
步骤4: 处理动态路由的 loading
动态路由loading:
• 在动态路由目录创建loading.tsx
• Next.js自动处理参数变化时的加载
• 无需手动管理loading状态
示例:
app/products/[id]/
├── loading.tsx # 参数变化时自动显示
└── page.tsx
当从/products/1导航到/products/2时,
loading.tsx会自动显示 - 5
步骤5: 优化加载体验
优化技巧:
• 使用骨架屏而不是简单的Spinner
• 保持加载UI与实际内容布局一致
• 使用动画(animate-pulse)提升体验
• 合理使用Suspense实现流式渲染
避免:
• 不要在所有地方都用loading.tsx
• 不要使用过于复杂的加载UI
• 不要忽略错误处理(配合error.tsx) - 6
步骤6: 测试和验证
测试要点:
• 测试页面导航时的加载状态
• 测试动态路由参数变化时的加载
• 测试慢网络下的加载体验
• 验证加载UI是否流畅
检查清单:
• 所有路由都有合适的loading状态
• 加载UI与实际内容布局一致
• 没有闪烁或布局偏移
• 用户体验流畅
常见问题
loading.tsx 和手写 useState 有什么区别?
loading.tsx 什么时候会显示?
Suspense 和 loading.tsx 有什么区别?
如何实现骨架屏?
动态路由的 loading 怎么处理?
可以自定义 loading 的样式吗?
loading.tsx 会影响性能吗?
16 分钟阅读 · 发布于: 2026年1月5日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南

评论
使用 GitHub 账号登录后即可评论