Next.js App Router 约定式文件实战:loading、error、not-found 怎么兜住加载态与异常
用 Next.js App Router 写页面,你迟早会遇到这三个问题:
- 页面里
await fetch(...)拉数据时,用户盯着一片空白,不知道是在加载还是卡死了。 - 接口挂了或抛异常,整个页面直接白屏崩掉,还可能把错误堆栈暴露给用户。
- 用户访问了一个不存在的资源(比如
/posts/99999),你想给个像样的 404 页,而不是默认那个丑页面。
很多人第一反应是在组件里手写 isLoading、try/catch、if (!data) return <NotFound/>。但 App Router 提供了约定式文件 ------在路由目录里放特定文件名,框架自动帮你接管加载态、错误态和 404。这篇把 loading.tsx、error.tsx、not-found.tsx 三个一次讲透。
朴素写法:所有状态挤在一个组件里
先看不用约定式文件时,一个详情页大概长这样:
tsx
// app/posts/[id]/page.tsx ------ 反面教材
'use client';
import { useEffect, useState } from 'react';
export default function PostPage({ params }: { params: { id: string } }) {
const [post, setPost] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(`/api/posts/${params.id}`)
.then(r => { if (!r.ok) throw new Error('加载失败'); return r.json(); })
.then(setPost)
.catch(setError)
.finally(() => setLoading(false));
}, [params.id]);
if (loading) return <p>加载中...</p>;
if (error) return <p>出错了</p>;
if (!post) return <p>找不到文章</p>;
return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}
问题很明显:业务渲染只有最后一行,前面全是状态判断的噪音;而且被迫写成客户端组件('use client'),丢掉了服务端组件直接 await 数据的能力。App Router 的约定式文件就是来拆掉这些样板的。
loading.tsx:自动加载态,基于 Suspense
在路由目录放一个 loading.tsx,Next.js 会自动用它包一层 Suspense。当同级 page.tsx(服务端组件)在 await 数据时,先渲染 loading.tsx 的内容,数据好了再换成真实页面:
tsx
// app/posts/[id]/loading.tsx
export default function Loading() {
// 骨架屏,比"加载中..."体验好得多
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-2/3 bg-gray-200 rounded" />
<div className="h-4 w-full bg-gray-200 rounded" />
<div className="h-4 w-5/6 bg-gray-200 rounded" />
</div>
);
}
有了它,page.tsx 就能回归纯粹的服务端组件,直接 await,不用管 loading:
tsx
// app/posts/[id]/page.tsx ------ 服务端组件,直接 await
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; // Next.js 15 起 params 是 Promise
const res = await fetch(`https://api.example.com/posts/${id}`);
const post = await res.json();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}
关键理解 :loading.tsx 的本质就是给 page.tsx 套了 <Suspense fallback={<Loading/>}>。所以它对「路由跳转时的加载」和「组件 await 期间」都生效,你什么都不用手写。
error.tsx:兜住渲染异常,必须是客户端组件
如果 page.tsx 里的 fetch 抛了异常,或者渲染过程报错,同级的 error.tsx 会接住它,展示降级 UI,而不是整页白屏:
tsx
// app/posts/[id]/error.tsx
'use client'; // ← error.tsx 必须是客户端组件,这是硬性要求
import { useEffect } from 'react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void; // 调它会重新渲染这段路由,相当于"重试"
}) {
useEffect(() => {
// 上报到监控系统,别把错误吞了
console.error('页面渲染出错:', error);
}, [error]);
return (
<div className="p-6 text-center">
<h2>😵 出了点问题</h2>
<p className="text-gray-500">{error.message}</p>
<button
onClick={() => reset()}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
重试
</button>
</div>
);
}
有两个坑要记牢:
坑一:error.tsx 必须加 'use client'。 它内部用 React 错误边界实现,错误边界只能是客户端组件。忘了加,构建直接报错。
坑二:error.tsx 抓不到同级 layout.tsx 的错误。 因为 error 边界包在 layout 内部,layout 本身抛错它管不着。要兜住 layout 的错误,得在上一级 目录放 error.tsx,或者用 global-error.tsx 兜住根 layout。
reset() 函数是 App Router 特有的:调用它会尝试重新渲染出错的这段路由子树,给用户一个「重试」而不用刷新整页。
not-found.tsx + notFound():优雅的 404
资源不存在时,别手动 return <div>404</div>。App Router 提供了 notFound() 函数,调用它会立即中断渲染,并渲染最近的 not-found.tsx:
tsx
// app/posts/[id]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const res = await fetch(`https://api.example.com/posts/${id}`);
if (res.status === 404) {
notFound(); // ← 中断渲染,跳到 not-found.tsx,后面代码不会执行
}
const post = await res.json();
return <article><h1>{post.title}</h1><p>{post.body}</p></article>;
}
tsx
// app/posts/[id]/not-found.tsx
import Link from 'next/link';
export default function NotFound() {
return (
<div className="p-6 text-center">
<h2>文章不存在</h2>
<p className="text-gray-500">你要找的文章可能已被删除。</p>
<Link href="/posts" className="text-blue-600 underline">
返回文章列表
</Link>
</div>
);
}
notFound() 的原理是抛出一个特殊错误,被框架捕获后渲染 not-found.tsx,同时响应状态码正确返回 404 ------这点对 SEO 很重要,搜索引擎知道这是不存在的页面,不会收录。手写 <div>404</div> 的话状态码还是 200,搜索引擎会以为是正常页。
它们的嵌套关系:一张图理清
这几个约定式文件不是平级的,它们的包裹顺序是固定的。同一个路由段里,Next.js 大致这样嵌套:
text
<layout>
<error> ← 兜住下面的渲染错误
<suspense fallback={<loading>}> ← loading 兜住加载态
<not-found 边界>
<page> ← 你的实际页面
</not-found>
</suspense>
</error>
</layout>
从这张图能推出几条实用结论:
loading和error都定义在路由段上,子路由可以有自己的一套,就近生效。error在layout内层,所以管不到同级 layout 的错误(要放上一级)。not-found既能被notFound()主动触发,也会兜住未匹配的路由。
小结
App Router 的三个约定式文件,把过去挤在组件里的状态处理拆成了框架职责:
loading.tsx:自动 Suspense fallback,让page.tsx回归纯服务端组件直接await,写骨架屏体验最佳。error.tsx:错误边界,必须'use client';提供reset()做重试;抓不到同级 layout 的错误,要放上一级。not-found.tsx+notFound():优雅 404,且返回正确的 404 状态码 ,对 SEO 友好;notFound()会中断后续渲染。
记忆点:别再手写 isLoading / try-catch / 404 判断------在路由目录里放对文件名,框架自动帮你套好 Suspense 和错误边界。