切换语言
切换主题

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.tsxSuspense

用了之后我才发现,原来管理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>
  );
}

看起来还行对吧?但问题在于:

  1. 代码冗余:每个页面都要写这一坨状态管理代码
  2. 状态割裂:loading、data、error分散在三个state里,容易出现状态不同步的bug
  3. 必须是Client Component:用了useState和useEffect,整个组件只能跑在客户端,失去了服务端渲染的优势
  4. 用户体验差:页面从点击到显示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>
  );
}

这个的好处是样式跟你的设计系统完全一致,不用额外调样式。

骨架屏的设计原则

不管用哪种方案,有几个设计原则要注意:

  1. 匹配实际布局:骨架屏的结构要跟真实内容的布局一致。比如文章列表有标题、摘要、标签,那骨架也要有对应的三块区域。

  2. subtle animation:动画不要太夸张,淡淡的闪烁就好。太花哨的动画会分散用户注意力,反而让等待感更强。

  3. 合理的数量:一般显示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边界。

  1. 安装 React DevTools 浏览器插件
  2. 打开开发者工具,切到 Components 标签
  3. 找到 <Suspense> 组件
  4. 右键点击,选择 “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。

总结

好了,咱们来快速回顾一下核心要点:

  1. loading.tsx是路由级loading的最佳实践:文件放在路由文件夹里,Next.js自动处理一切。告别手写useState,代码清爽一半。

  2. 骨架屏比spinner体验更好:提前展示布局,降低用户焦虑。用纯CSS、react-loading-skeleton或UI库实现都行,看项目需求。

  3. Suspense要放在组件树上层:它是个闸门,监控下面所有异步操作。放错位置就不生效。

  4. 动态路由记得加key:否则切换ID时loading不显示。<Suspense key={params.id}>这一行别忘了。

  5. 多数据源按需拆分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

    步骤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

    步骤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

    步骤3: 使用 Suspense 包裹异步组件

    在组件中使用Suspense:
    • 包裹异步数据获取的组件
    • 设置fallback显示加载状态
    • 支持嵌套Suspense实现细粒度加载

    示例:
    <Suspense fallback={<Loading />}>
    <AsyncComponent />
    </Suspense>

    多个组件可以分别用Suspense包裹:
    • 每个组件独立加载
    • 快的先显示,慢的后显示
    • 提升用户体验
  4. 4

    步骤4: 处理动态路由的 loading

    动态路由loading:
    • 在动态路由目录创建loading.tsx
    • Next.js自动处理参数变化时的加载
    • 无需手动管理loading状态

    示例:
    app/products/[id]/
    ├── loading.tsx # 参数变化时自动显示
    └── page.tsx

    当从/products/1导航到/products/2时,
    loading.tsx会自动显示
  5. 5

    步骤5: 优化加载体验

    优化技巧:
    • 使用骨架屏而不是简单的Spinner
    • 保持加载UI与实际内容布局一致
    • 使用动画(animate-pulse)提升体验
    • 合理使用Suspense实现流式渲染

    避免:
    • 不要在所有地方都用loading.tsx
    • 不要使用过于复杂的加载UI
    • 不要忽略错误处理(配合error.tsx)
  6. 6

    步骤6: 测试和验证

    测试要点:
    • 测试页面导航时的加载状态
    • 测试动态路由参数变化时的加载
    • 测试慢网络下的加载体验
    • 验证加载UI是否流畅

    检查清单:
    • 所有路由都有合适的loading状态
    • 加载UI与实际内容布局一致
    • 没有闪烁或布局偏移
    • 用户体验流畅

常见问题

loading.tsx 和手写 useState 有什么区别?
loading.tsx是Next.js的约定,自动在页面加载时显示,无需手动管理状态。手写useState需要每个页面都写一遍,代码重复且容易出错。loading.tsx代码量减少50%,用户体验更好,支持流式渲染。
loading.tsx 什么时候会显示?
loading.tsx会在以下情况自动显示:1) 用户导航到该路由时;2) 动态路由参数变化时;3) 父路由加载时。Next.js会自动管理显示时机,无需手动控制。当页面数据加载完成后,loading.tsx会自动隐藏。
Suspense 和 loading.tsx 有什么区别?
loading.tsx是路由级别的加载状态,适用于整个页面。Suspense是组件级别的,可以包裹特定的异步组件,实现更细粒度的加载控制。可以同时使用:路由用loading.tsx,组件内用Suspense。
如何实现骨架屏?
在loading.tsx中使用Skeleton组件,模拟实际内容的布局。使用Tailwind的animate-pulse类添加动画效果。保持骨架屏的布局与实际内容一致,这样加载完成后不会有布局偏移。
动态路由的 loading 怎么处理?
在动态路由目录创建loading.tsx即可。例如app/products/[id]/loading.tsx。当用户从/products/1导航到/products/2时,Next.js会自动显示loading.tsx,无需手动管理参数变化。
可以自定义 loading 的样式吗?
可以。loading.tsx就是一个普通的React组件,可以完全自定义样式。可以使用Tailwind CSS、CSS Modules、styled-components等任何样式方案。建议使用骨架屏而不是简单的Spinner,提升用户体验。
loading.tsx 会影响性能吗?
不会,反而会提升性能。loading.tsx支持流式渲染(Streaming),页面可以分块加载,用户不需要等待整个页面加载完成。快的部分先显示,慢的部分后显示,整体体验更好。

16 分钟阅读 · 发布于: 2026年1月5日 · 修改于: 2026年1月22日

评论

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

相关文章