Loading、Error 与 Not Found:特殊约定文件
第5章:Loading、Error 与 Not Found:特殊约定文件
任何异步操作都有三种结局:成功、失败、等待中。Next.js 将这三种状态提升为框架级约定,每个路由区域都能拥有独立的加载、错误和 404 体验。
本章核心问题:loading.tsx、error.tsx 和 not-found.tsx 各自如何工作?它们的作用域规则是什么?
读完本章你将理解:
- loading.tsx 基于 React 流式渲染的即时响应机制
- error.tsx 的作用域(不捕获同级 layout 错误)以及 global-error.tsx 的兜底角色
- not-found.tsx 与 notFound() 函数的协作,以及与 error.tsx 的语义区分
Level 1 · 你需要知道的(1-3年经验)
为什么需要这三类特殊文件
在 Web 应用中,任何异步操作都有三种结局:成功、失败、等待中。任何资源都可能不存在。这是应用状态空间的基本结构,无论框架是否帮你处理,这三种情况都会发生。
Next.js App Router 选择了将这三种状态提升为框架级约定:
loading.tsx— 处理"等待中"状态error.tsx— 处理"失败"状态not-found.tsx— 处理"资源不存在"状态
这三个文件遵循相同的作用域规则:每个文件只作用于当前目录及其子目录。这意味着你可以针对不同的路由区域定义不同的加载、错误和 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:
error: Error & { digest?: string }— 被捕获的错误对象reset: () => void— 重试当前路由段的函数
// 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.tsx 与 notFound() 函数配合工作。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 页面,在以下情况触发:
- 路由根本不存在(URL 无法匹配任何路由)
- 代码中调用了
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)
在实际项目中,推荐的最小配置是:
app/not-found.tsx— 全局 404app/global-error.tsx— 根布局错误兜底app/error.tsx— 全局运行时错误- 每个主要路由区域(如
app/(app)/error.tsx)有针对性的错误页面
过于细粒度的 not-found.tsx 和 error.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.tsx、error.tsx 和 not-found.tsx 是 App Router 将应用状态三要素(加载中、失败、不存在)提升为框架约定的体现。loading.tsx 利用 React 流式渲染让用户立即看到响应,消除了"等待白屏"问题。error.tsx 作为客户端错误边界,将运行时错误转化为可恢复的 UI,error.digest 连接客户端展示与服务端日志。global-error.tsx 作为最后防线,处理根布局级别的崩溃。not-found.tsx 配合 notFound() 函数处理业务层的"资源不存在"场景,与技术错误保持语义清晰的分离。这套体系的作用域规则(文件作用于当前目录及子目录)让不同路由区域拥有完全独立的错误处理体验,同时通过层级冒泡确保没有未处理的状态。