App 与 Layout — CodeRoadMapApp 与 Layout
这一课你将能做到
- 用
layout.tsx 和 template.tsx 组合出多层嵌套布局,理解二者的差异
- 用
loading.tsx 实现路由切换和 Suspense 区域的双层 loading 效果
- 用
error.tsx 捕获当前层级的渲染错误并给出友好降级
- 用
not-found.tsx 实现自定义 404,用 notFound() 函数主动触发
- 理解嵌套布局是如何逐层包裹的——全局 layout → 路由组 layout → 页面
为什么要学
上一课学了路由结构,这一课解决「路由有了,页面怎么拼起来」的问题。一个真实网站的页面结构是嵌套的:外层有 html/body(根 layout)→ 营销区有顶部导航(路由组 layout)→ 文档区有左侧目录(子路由 layout)→ 具体内容(page)。切换页面时,导航栏不能重新闪一下、左侧目录不能重新请求一次——这要求 layout 有状态持久化能力。
人话:你逛街进商场,大门(根 layout)一直开着;你换店(换 page),但商场的天花板、地板、背景音乐(layout)不会因为你换了一家店就重新装修一次。
先建立图像
URL: /dashboard/settings
渲染树(自外向内包裹):
<html lang="zh-CN"> ← app/layout.tsx (RootLayout)
<body>
<div class="root-wrapper"> ← 全局根布局
<header>全局导航</header>
<main>
<div class="dashboard-layout"> ← app/(dashboard)/layout.tsx
<aside>仪表盘侧边栏</aside>
<div class="page-area">
<div class="settings-layout"> ← app/(dashboard)/settings/layout.tsx
<div class="tabs">设置 Tab</div>
<div class="content">
<SettingsPage /> ← app/(dashboard)/settings/page.tsx
</div>
</div>
</div>
</div>
</main>
<footer>全局页脚</footer>
</div>
</body>
</html>
切换 /dashboard/settings → /dashboard/profile 时:
app/layout.tsx 不重新渲染
app/(dashboard)/layout.tsx 不重新渲染
app/(dashboard)/settings/layout.tsx 会卸载(因为路径从 settings 变到 profile,不在 settings 段了)
page.tsx 变成新的 page
跟着做
步骤 1:全局根布局 layout.tsx
打开 app/layout.tsx,这是所有页面的最外层:
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: { default: '我的站', template: '%s | 我的站' },
description: '用 Next.js 构建',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="zh-CN">
<body>
<header style={{ background: '#222', color: '#fff', padding: 16 }}>
全局导航
</header>
<main>{children}</main>
<footer style={{ background: '#f5f5f5', padding: 16, marginTop: 40 }}>
全局页脚
</footer>
</body>
</html>
);
}
title.template:子页面用 export const metadata = { title: '关于' },最终浏览器标题变成 关于 | 我的站。
<html> 和 <body> 必须在这里出现,App Router 不会自动注入。
步骤 2:嵌套布局
新建 app/dashboard/layout.tsx:
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: 'flex', minHeight: 'calc(100vh - 120px)' }}>
<aside style={{ width: 200, borderRight: '1px solid #ddd', padding: 20 }}>
<nav>
<p><a href="/dashboard">首页</a></p>
<p><a href="/dashboard/settings">设置</a></p>
<p><a href="/dashboard/profile">资料</a></p>
</nav>
</aside>
<section style={{ flex: 1, padding: 24 }}>{children}</section>
</div>
);
}
新建 app/dashboard/page.tsx:
export default function DashboardHome() {
return (
<div>
<h1>仪表盘首页</h1>
<p>切换 /dashboard/settings 或 /dashboard/profile,侧边栏不会重载。</p>
</div>
);
}
新建 app/dashboard/settings/page.tsx:
export default function SettingsPage() {
return <h2>设置页面</h2>;
}
新建 app/dashboard/profile/page.tsx:
export default function ProfilePage() {
return <h2>个人资料</h2>;
}
打开浏览器开发者工具 → Elements 面板,切换三个页面,观察 DOM 结构:<aside> 一直存在,只有 <section> 内的内容在变。
步骤 3:template.tsx 的作用
在 app/dashboard/settings/layout.tsx 旁边建 app/dashboard/settings/template.tsx:
'use client';
import { useEffect } from 'react';
export default function SettingsTemplate({ children }: { children: React.ReactNode }) {
useEffect(() => {
console.log('SettingsTemplate 挂载了');
}, []);
return (
<div style={{ border: '2px dashed #999', padding: 16 }}>
<p>template 区域(每次切出 settings 再回来,这段控制台会重新打印)</p>
{children}
</div>
);
}
然后切换到 /dashboard/settings 外的其他页面再切回来,控制台每次都会打印。把 template.tsx 改成 layout.tsx(或者删掉 template),再试:切回来不会打印。这就是状态持久化 vs 状态重置的区别。
步骤 4:loading.tsx 最简用法
在 app/dashboard/settings/loading.tsx 中:
export default function SettingsLoading() {
return <p>设置页加载中(loading.tsx)…</p>;
}
为了让效果可见,给 app/dashboard/settings/page.tsx 加个异步延迟:
async function getData() {
await new Promise(r => setTimeout(r, 2000));
return { items: ['通知', '安全', '隐私'] };
}
export default async function SettingsPage() {
const data = await getData();
return (
<div>
<h2>设置页面</h2>
<ul>{data.items.map(i => <li key={i}>{i}</li>)}</ul>
</div>
);
}
切到 /dashboard/settings,先看到 loading 文字,2 秒后看到内容。loading.tsx 会作为该段及其子段的 Suspense fallback。
如果想更精细控制,可以在 page 内部用 <Suspense> 包裹局部区域,配合更小的 loading.tsx:
import { Suspense } from 'react';
import SlowComponent from './SlowComponent';
export default function Page() {
return (
<div>
<h1>标题立刻出现</h1>
<Suspense fallback={<p>列表加载中…</p>}>
<SlowComponent />
</Suspense>
</div>
);
}
步骤 5:error.tsx 捕获渲染错误
'use client';
import { useEffect } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Dashboard 错误:', error);
}, [error]);
return (
<div style={{ padding: 24, border: '2px solid #e00', borderRadius: 8 }}>
<h2>出错了</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
);
}
app/dashboard/error/page.tsx(一个必炸的页面):
export default function ErrorPage() {
throw new Error('模拟的渲染错误');
}
访问 /dashboard/error,会看到 error.tsx 渲染的降级 UI。点击「重试」,Next.js 会重新尝试渲染该段。
error.tsx 必须是 Client Component(因为它内部用 useEffect 和 reset),写在哪个层级就捕获哪个层级的错误。子层级不写会向上冒泡到最近的 error.tsx。
步骤 6:not-found.tsx 与 notFound()
app/not-found.tsx(全局 404):
export default function NotFound() {
return (
<main style={{ padding: 40, textAlign: 'center' }}>
<h1>404</h1>
<p>页面不存在。</p>
<a href="/">返回首页</a>
</main>
);
}
app/blog/[slug]/page.tsx(文章不存在时主动抛 404):
import { notFound } from 'next/navigation';
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await fetch(`https://api.example.com/posts/${slug}`).then(r =>
r.ok ? r.json() : null
);
if (!post) {
notFound(); // 触发最近的 not-found.tsx
}
return (
<main>
<h1>{post.title}</h1>
<p>{post.content}</p>
</main>
);
}
在 app/blog/not-found.tsx 写一个博客区专属的 404:
export default function BlogNotFound() {
return (
<div>
<h2>这篇文章找不到了</h2>
<p>它可能已被删除或从未存在过。</p>
</div>
);
}
访问不存在的 /blog/no-such-article,触发的是 app/blog/not-found.tsx;访问 /no-such-route 触发的是根 app/not-found.tsx。Next.js 会找当前段最近的 not-found.tsx。
讲清楚要点
- layout 是持久化容器:同一路由段内切换子页面时,layout 组件状态保留、DOM 不重新创建。适合做导航栏、侧边栏、全局弹窗。
- template 是每次重新挂载的容器:和 layout 功能一样(包裹 children),但每次路由切换都卸载+重挂载,状态清空。适合做「每次进入都要重新执行的动画、埋点、定时器」。
- loading.tsx = Suspense fallback:Next.js 自动把
loading.tsx 包在 Suspense 里,作为该段内异步操作的兜底。颗粒度:整个段都可以被这个 loading 覆盖。
- error.tsx = Error Boundary:必须是
'use client',接收 error 和 reset 两个参数。写在哪个层级就捕获哪个层级的 React 渲染错误。子段没写就向上冒泡。注意:它捕获的是渲染时的 throw,不是事件处理里的 throw。
- notFound() 是编程式 404:只能在 Server Component、Server Action、Route Handler 里调用,不是客户端 API。调用后中断当前渲染,渲染最近的
not-found.tsx。
<html> 和 <body> 只能出现在根 layout:如果嵌套 layout 里再包一层 <html>,构建报错。子级 layout 用 <div> 或 <section> 组织结构即可。
- metadata 是 Server Component API:在 layout 或 page 里
export const metadata,Next.js 自动注入到 <head>。不能在 Client Component 里导出 metadata。
你可能会懵的地方
-
为什么 error.tsx 必须是 'use client'?
因为它内部用 useEffect 和 reset() 函数,这两个都是客户端特性。Next.js 的 Error Boundary 本质上还是 React 的类组件 ErrorBoundary,需要客户端交互能力。
-
error.tsx 为什么捕获不了 page.tsx 里 await fetch 的报错?
能捕获。只要 fetch 抛异常没有被 try/catch,await 后的异常会中断渲染,被最近的 error.tsx 捕获。但如果你自己 try/catch 了,错误被吞掉,error.tsx 看不到。
-
loading.tsx 和 page.tsx 的 loading 有什么区别?
loading.tsx 是 Next.js 自动帮你包在 Suspense 边界里,覆盖整个路由段。如果你嫌颗粒太大,可以在 page.tsx 里自己写 <Suspense fallback={...}>,包裹局部区域。
-
notFound() 和 redirect() 可以一起用吗?
不是一起用,而是二选一:查询到数据缺失用 notFound() 渲染 404;需要换 URL 用 redirect('/new-url')。两者都是服务端函数,调用后中断当前渲染。
-
可以在 layout 里获取 params 吗?
可以。app/dashboard/layout.tsx 接收 { children, params },其中 params 是该段匹配的动态路由参数。但要注意 Next.js 15 中 params 是 Promise,需要 await。
-
根 layout 里能写 loading/error 吗?
根 app/layout.tsx 不能有自己的 loading.tsx 或 error.tsx——因为 React 还没挂载,ErrorBoundary/Suspense 不存在。全局 loading 和全局 error 要借助 app/template.tsx(用 useEffect 做初始化 loading)或顶层 _app.tsx 概念不存在了。但根 layout 能用自己的 not-found.tsx。
错法对照
| 错法 | 问题 | 正解 |
|---|
子 layout 里再包 <html> | 每个页面只能有一个 html 根 | 只在 app/layout.tsx 包 html/body |
error.tsx 没写 'use client' | 构建报错,reset 无法使用 | 顶部加 'use client' |
| 在 Client Component 里 export metadata | metadata 不支持客户端导出 | 把 metadata 拆到 server layout 或单独 page |
notFound() 在事件回调里调用 | notFound 只能在服务端调用 | 事件里用 router.push('/404') 或直接状态显示 |
用 throw new Error 代替 notFound() | 触发 error.tsx,HTTP 200 | 用 notFound(),HTTP 404,走 not-found.tsx |
以为 template.tsx 比 layout.tsx 更高级 | 两者不替代,同层都写时 template 包 layout | 明确需求:要状态保留用 layout;要重置用 template |
卡点急救
- 页面加载时没有任何 loading:Server Component 渲染太快了,看不到。给数据请求加
await new Promise(r => setTimeout(r, 3000)) 测试效果。
- error.tsx 没触发:看控制台,如果是 fetch 网络错误但没抛异常(Next.js 默认 fetch 缓存行为),错误不会中断渲染,error.tsx 不触发。
- notFound() 触发但渲染的不是我写的 not-found.tsx:检查 not-found.tsx 的位置。
notFound() 触发的是当前段最近的 not-found.tsx。如果你在 app/blog/[slug]/page.tsx 调 notFound,但 app/blog/not-found.tsx 不存在,它会向上找到 app/not-found.tsx。
- metadata 不生效:确认你是在 Server Component(没有
'use client')里 export 的 metadata。metadata 不能从客户端组件导出。
- 布局切换时侧边栏闪了一下:检查侧边栏组件是不是放在了 template.tsx 里,或侧边栏内的数据获取没在 layout 内做缓存。layout 不重置但内部状态可以重置——如果侧边栏组件每次 mount 都重新 fetch,就会闪。
面试常问
-
layout 和 template 的区别?
两者都包裹 children,都参与嵌套。layout 在子路由切换时保持挂载状态、DOM 不重建;template 每次路由切换都卸载后重建,状态重置。模板适合做动画、PV 统计、定时器;布局适合做导航、侧边栏。
-
error.tsx 的工作原理?
Next.js 把 error.tsx 包装成 React ErrorBoundary。同段内任何渲染时抛出的异常(throw Error、await fetch 抛错未 catch)被它捕获,渲染降级 UI。reset() 让 Next.js 重新尝试渲染该段。注意 error.tsx 必须是客户端组件。
-
notFound() 和 redirect() 的区别?
notFound() 中断渲染并渲染 not-found.tsx,HTTP 状态码 404,URL 不变;redirect() 中断渲染并返回 307/308 重定向,URL 变化,浏览器重新请求新地址。两者都只能在服务端调用。
-
metadata 怎么做到 title template?
根 layout 导出 metadata.title = { default: '默认标题', template: '%s | 站点名' },子页面导出 metadata.title = '当前页标题',最终合成 当前页标题 | 站点名。
-
根 layout 为什么不能有 loading.tsx?
React 的 Suspense 边界和 ErrorBoundary 都需要挂载在 DOM 树上,而根 layout 负责渲染 <html><body>,是 React 挂载的最外层。React 没有外层再包一个 Suspense,所以不支持。但根 layout 本身可以包含 <Suspense> 包裹子内容。
自测三问
/dashboard/settings 切换到 /dashboard/profile,哪几层 layout 会重新挂载?哪几层不会?
error.tsx 为什么必须是 'use client'?如果忘了加,报错是什么?
notFound() 和 throw new Error 分别会触发哪个保留文件?HTTP 状态码分别是多少?
小结一句话
App Router 的保留文件系统(layout/template/loading/error/not-found)是一套声明式的 UI 状态机:持久化包裹用 layout、重置用 template、异步兜底用 loading、错误降级用 error、缺失资源用 not-found——不用写 if/else,按约定放文件即可。
延伸