第 5 章

Loading、Error 与 Not Found:特殊约定文件

第5章:Loading、Error 与 Not Found:特殊约定文件

任何异步操作都有三种结局:成功、失败、等待中。Next.js 将这三种状态提升为框架级约定,每个路由区域都能拥有独立的加载、错误和 404 体验。

本章核心问题:loading.tsx、error.tsx 和 not-found.tsx 各自如何工作?它们的作用域规则是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

为什么需要这三类特殊文件

在 Web 应用中,任何异步操作都有三种结局:成功、失败、等待中。任何资源都可能不存在。这是应用状态空间的基本结构,无论框架是否帮你处理,这三种情况都会发生。

Next.js App Router 选择了将这三种状态提升为框架级约定

这三个文件遵循相同的作用域规则:每个文件只作用于当前目录及其子目录。这意味着你可以针对不同的路由区域定义不同的加载、错误和 404 体验,而不是全应用共用一套。

Level 2 · 它是怎么运行的(3-5年经验)

loading.tsx:流式渲染的视觉基础

它是如何工作的

当你在路由目录中创建 loading.tsx,Next.js 自动将该目录的 page.tsx 包裹在 <Suspense> 中,并将 loading.tsx 的输出作为 fallback:

// Next.js 内部做的事(伪代码)
<Suspense fallback={<LoadingComponent />}>
  <PageComponent />
</Suspense>

这利用了 React 的流式渲染机制:服务器首先发送包含 loading.tsx 内容的 HTML(称为"初始 shell"),当 page.tsx 在服务器完成异步操作(数据库查询、API 调用)后,剩余 HTML 通过 HTTP 流追加到响应中,浏览器用它替换 loading 状态。

这个过程不需要客户端发起额外请求——整个内容都通过单个 HTTP 响应流式传输。用户看到的是:页面立即响应(加载骨架屏出现),然后真实内容"流入"。

关键特性:路由立即响应

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-10 bg-gray-200 rounded w-32 mb-8 animate-pulse" />
      <div className="space-y-6">
        {Array.from({ length: 5 }).map((_, i) => (
          <div key={i} className="animate-pulse">
            <div className="h-6 bg-gray-200 rounded w-3/4 mb-2" />
            <div className="h-4 bg-gray-200 rounded w-1/4 mb-3" />
            <div className="space-y-2">
              <div className="h-4 bg-gray-200 rounded" />
              <div className="h-4 bg-gray-200 rounded" />
              <div className="h-4 bg-gray-200 rounded w-5/6" />
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}
// app/blog/[slug]/loading.tsx — 文章页加载状态,更针对性的骨架
export default function PostLoading() {
  return (
    <article className="max-w-3xl mx-auto px-4 py-8 animate-pulse">
      {/* 标题骨架 */}
      <div className="h-10 bg-gray-200 rounded w-4/5 mb-4" />
      {/* 元信息骨架 */}
      <div className="flex items-center gap-4 mb-8">
        <div className="h-10 w-10 bg-gray-200 rounded-full" />
        <div className="h-4 bg-gray-200 rounded w-32" />
        <div className="h-4 bg-gray-200 rounded w-24" />
      </div>
      {/* 封面图骨架 */}
      <div className="h-64 bg-gray-200 rounded-lg mb-8" />
      {/* 正文骨架 */}
      <div className="space-y-3">
        {Array.from({ length: 8 }).map((_, i) => (
          <div
            key={i}
            className={`h-4 bg-gray-200 rounded ${
              i % 4 === 3 ? 'w-3/4' : 'w-full'
            }`}
          />
        ))}
      </div>
    </article>
  );
}

与手动 Suspense 的关系

loading.tsx路由级别的自动 Suspense。对于组件级别的 Suspense(例如,页面中某个数据密集型组件独立加载),仍然需要手动使用 <Suspense>

// app/dashboard/page.tsx — 混合使用:路由级 loading + 组件级 Suspense
import { Suspense } from 'react';
import { RealtimeChart } from './RealtimeChart';
import { ActivityFeed } from './ActivityFeed';

// dashboard/loading.tsx 处理整个页面的初始加载
// 页面内部的组件可以有自己的 Suspense 边界

export default async function DashboardPage() {
  // 关键数据在此获取,loading.tsx 覆盖等待期
  const summary = await fetchSummary();

  return (
    <div className="grid grid-cols-3 gap-6">
      <SummaryCards data={summary} />

      {/* 图表数据较慢,用独立的 Suspense 处理 */}
      <Suspense fallback={<ChartSkeleton />}>
        <RealtimeChart />
      </Suspense>

      {/* 活动流也独立加载 */}
      <Suspense fallback={<FeedSkeleton />}>
        <ActivityFeed />
      </Suspense>
    </div>
  );
}

这个模式让关键内容(SummaryCards)优先显示,非关键的慢速内容(图表、活动流)独立流入,不阻塞主要内容的渲染。

error.tsx:运行时错误的优雅降级

为什么必须是客户端组件

error.tsx 必须声明 'use client',这不是可选的。原因在于:React 错误边界(Error Boundary)需要 componentDidCatch 生命周期方法或等效的 hook,而这些只在客户端 React 中存在。服务器组件没有"错误边界"的概念——服务器组件的错误直接导致响应失败,由其他机制处理。

error.tsx 接收两个 props:

// app/blog/[slug]/error.tsx — 文章页错误处理
'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';

interface Props {
  error: Error & { digest?: string };
  reset: () => void;
}

export default function PostError({ error, reset }: Props) {
  useEffect(() => {
    // 上报到错误监控(Sentry、Datadog 等)
    reportError(error, {
      digest: error.digest,
      context: 'blog-post',
    });
  }, [error]);

  return (
    <div className="max-w-3xl mx-auto px-4 py-16 text-center">
      <div className="text-6xl mb-6">😕</div>
      <h2 className="text-2xl font-bold mb-3">加载文章时出现了问题</h2>
      <p className="text-gray-600 mb-8">
        我们已收到错误报告,正在处理。你可以稍后重试。
      </p>

      {/* error.digest 是服务端错误的标识符,可用于客服对话 */}
      {error.digest && (
        <p className="text-xs text-gray-400 mb-6 font-mono">
          错误 ID: {error.digest}
        </p>
      )}

      <div className="flex gap-3 justify-center">
        <button
          onClick={reset}
          className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
        >
          重试
        </button>
        <a
          href="/blog"
          className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
        >
          返回博客
        </a>
      </div>
    </div>
  );
}

error.tsx 的作用域

error.tsx 捕获的是当前目录的 page.tsx 和子路由中的错误,但不捕获同级 layout.tsx 中的错误。这个设计是有意为之的:如果布局本身出错,错误边界不能渲染在出错的布局里。

app/
└── blog/
    ├── layout.tsx     ← error.tsx 不捕获这里的错误
    ├── error.tsx      ← 捕获 page.tsx 和子路由的错误
    ├── page.tsx       ← 这里的错误被 error.tsx 捕获
    └── [slug]/
        ├── page.tsx   ← 这里的错误也被 blog/error.tsx 捕获(如果 [slug] 没有自己的 error.tsx)
        └── error.tsx  ← 如果存在,优先捕获 [slug]/page.tsx 的错误

global-error.tsx:根布局错误处理

当根 layout.tsx 本身发生错误时,普通的 error.tsx 无法处理,因为它们都在根布局之内。这时需要 app/global-error.tsx

// app/global-error.tsx — 最后一道防线
'use client';

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  // global-error 替代根布局渲染,因此需要自己提供 html/body
  return (
    <html>
      <body>
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            minHeight: '100vh',
            fontFamily: 'system-ui, sans-serif',
            textAlign: 'center',
            padding: '2rem',
          }}
        >
          <h1 style={{ fontSize: '1.5rem', fontWeight: 'bold', marginBottom: '1rem' }}>
            应用发生了严重错误
          </h1>
          <p style={{ color: '#666', marginBottom: '2rem' }}>
            请刷新页面,或联系支持团队。
          </p>
          {error.digest && (
            <p style={{ fontSize: '0.75rem', color: '#999', marginBottom: '1rem' }}>
              错误 ID: {error.digest}
            </p>
          )}
          <button
            onClick={reset}
            style={{
              padding: '0.5rem 1.5rem',
              background: '#2563eb',
              color: 'white',
              border: 'none',
              borderRadius: '0.375rem',
              cursor: 'pointer',
            }}
          >
            重新加载
          </button>
        </div>
      </body>
    </html>
  );
}

注意:global-error.tsx 替代根布局渲染,因此它必须包含完整的 <html><body> 标签。开发环境中,Next.js 会显示错误覆盖层而不是 global-error.tsx,后者只在生产构建中生效。

Level 3 · 规范怎么定义的(资深)

not-found.tsx:资源缺失的优雅处理

notFound() 函数与文件的协作

not-found.tsxnotFound() 函数配合工作。notFound() 是从 next/navigation 导入的函数,调用它会停止当前组件的渲染,将控制权交给最近的 not-found.tsx

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/data';

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  // 文章不存在时,触发 not-found.tsx
  // 注意:notFound() 之后的代码不会执行(它抛出一个特殊错误)
  if (!post) notFound();

  // 文章已删除(软删除)但 URL 还存在
  if (post.deletedAt) notFound();

  return <PostView post={post} />;
}
// app/blog/[slug]/not-found.tsx — 文章不存在页面
import Link from 'next/link';
import { Button } from '@/components/ui/button';

export default function PostNotFound() {
  return (
    <div className="max-w-3xl mx-auto px-4 py-16 text-center">
      <h1 className="text-4xl font-bold text-gray-900 mb-4">
        404
      </h1>
      <h2 className="text-xl font-semibold text-gray-700 mb-3">
        文章不存在
      </h2>
      <p className="text-gray-500 mb-8">
        你访问的文章可能已被删除,或者链接有误。
      </p>
      <div className="flex gap-3 justify-center">
        <Button asChild>
          <Link href="/blog">浏览所有文章</Link>
        </Button>
        <Button variant="outline" asChild>
          <Link href="/">返回首页</Link>
        </Button>
      </div>
    </div>
  );
}

全局 not-found.tsx

app/not-found.tsx(根目录)是全局 404 页面,在以下情况触发:

  1. 路由根本不存在(URL 无法匹配任何路由)
  2. 代码中调用了 notFound() 但当前目录没有 not-found.tsx
// app/not-found.tsx — 全局 404 页面
import Link from 'next/link';
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: '页面不存在',
  robots: { index: false },
};

export default function GlobalNotFound() {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center">
      <div className="text-center">
        <p className="text-8xl font-black text-gray-200 mb-6">404</p>
        <h1 className="text-3xl font-bold text-gray-900 mb-3">
          页面不存在
        </h1>
        <p className="text-gray-600 max-w-md mb-8">
          你要访问的页面可能已被移动、删除,或者从未存在过。
        </p>
        <Link
          href="/"
          className="inline-flex items-center px-6 py-3 bg-blue-600 text-white
                     rounded-lg font-medium hover:bg-blue-700 transition-colors"
        >
          ← 返回首页
        </Link>
      </div>
    </div>
  );
}

not-found 与 error 的区别

这两个文件经常被混淆,但它们处理本质不同的情况:

特性 not-found.tsx error.tsx
触发条件 资源不存在(业务逻辑) 运行时异常(技术错误)
触发方式 notFound() 函数 或 路由不存在 throw 或 Promise rejected
HTTP 语义 404 Not Found 500 Internal Server Error
是否必须是客户端 否(可以是服务器组件) 是(必须是客户端组件)
用户期望 内容不在这里 出了问题

notFound() 处理的是预期内的"无内容"情况:查询结果为空、资源已删除、权限不足导致的不显示。error.tsx 处理的是意外的技术错误:数据库连接失败、外部 API 超时、代码 bug。

完整的错误处理体系

将三个文件和 HTTP 状态码组合,构成完整的路由错误处理体系:

用户请求一个 URL
    │
    ├── URL 在路由表中不存在
    │       └── → app/not-found.tsx(HTTP 404)
    │
    ├── URL 存在,进入路由渲染
    │       │
    │       ├── page.tsx 调用 notFound()
    │       │       └── → 最近的 not-found.tsx(HTTP 404)
    │       │
    │       ├── page.tsx 中发生未捕获的异常
    │       │       └── → 最近的 error.tsx(HTTP 500)
    │       │
    │       ├── layout.tsx 中发生异常
    │       │       └── → 父级 error.tsx 或 global-error.tsx
    │       │
    │       └── 数据获取中(Suspense 挂起)
    │               └── → 最近的 loading.tsx(HTTP 200 流式)
    │
    └── 路由正常渲染
            └── → page.tsx 内容(HTTP 200)

在实际项目中,推荐的最小配置是:

过于细粒度的 not-found.tsxerror.tsx(每个动态路由都有)在大多数情况下是过度设计,除非不同区域的错误体验确实需要显著差异。

Level 4 · 边界与陷阱(所有人)

陷阱1:error.tsx 捕获的是当前目录的 page.tsx 和子路由中的错误,但不捕获同级 layout.tsx 中的错误——布局错误需要父级 error.tsx 或 global-error.tsx 处理。

陷阱2:global-error.tsx 替代根布局渲染,因此它必须包含完整的 和 标签,且仅在生产构建中生效。

陷阱3:过于细粒度的 error.tsx 和 not-found.tsx(每个动态路由都有)在大多数情况下是过度设计——除非不同区域的错误体验确实需要显著差异。

小结

loading.tsxerror.tsxnot-found.tsx 是 App Router 将应用状态三要素(加载中、失败、不存在)提升为框架约定的体现。loading.tsx 利用 React 流式渲染让用户立即看到响应,消除了"等待白屏"问题。error.tsx 作为客户端错误边界,将运行时错误转化为可恢复的 UI,error.digest 连接客户端展示与服务端日志。global-error.tsx 作为最后防线,处理根布局级别的崩溃。not-found.tsx 配合 notFound() 函数处理业务层的"资源不存在"场景,与技术错误保持语义清晰的分离。这套体系的作用域规则(文件作用于当前目录及子目录)让不同路由区域拥有完全独立的错误处理体验,同时通过层级冒泡确保没有未处理的状态。

本章评分
4.9  / 5  (63 评分)

💬 留言讨论