page / layout / loading / error / not-found / template / default 各管什么:一张对照表 + 嵌套层级示意;本页自带真实 loading.tsx 演示 Suspense 边界
文件名即配置:放在哪一级目录,就管哪一级路由段
| 约定文件 | 职责 | 渲染时机 | 必须 Client? |
|---|---|---|---|
| page.tsx | 该路由段的页面主体,export default 即页面 | 每次访问该路由时 | 否(默认 Server) |
| layout.tsx | 包裹本段及所有子段的共享外壳 | 进入该段时挂载,段内导航保持不卸载 | 否 |
| loading.tsx | 给本段 page 自动包一层 Suspense 的 fallback | 页面渲染(取数)未完成期间 | 否 |
| error.tsx | 给本段包一层 Error Boundary,接住渲染错误 | 本段渲染抛错时替换 page | 是 |
| not-found.tsx | notFound() 抛出时的兜底 UI | 本段调用 notFound() 或访问未匹配路由时 | 否 |
| template.tsx | 与 layout 同位的外壳,但每次导航重挂载 | 每次进入本段都重新挂载 | 否 |
| default.tsx | 并行路由(@slot)在当前 URL 下无匹配时的兜底 | 并行路由槽位失配 / 硬刷新恢复时 | 否 |
layout 是套娃:每多一层目录就多一层可选外壳;导航时只有叶子 page 被替换
app/ ├── layout.tsx ← 根布局:全局唯一,<html>/<body> 只能在这写 │ ├── page.tsx ← 首页(路由 /) │ └── router/ │ ├── layout.tsx ← 嵌套布局:包 /router 下所有页(可选,没有就透传) │ ├── dynamic-routes/ │ │ ├── page.tsx ← 列表页 │ │ └── [id]/ │ │ └── page.tsx ← 详情页:Server + generateMetadata │ ├── errors/ │ │ ├── error.tsx ← 渲染失败(必须 Client) │ │ ├── not-found.tsx ← notFound(),不进 error.tsx │ │ └── page.tsx │ └── conventions/ │ ├── loading.tsx ← 本页的骨架屏(本页的活例子) │ └── page.tsx ← 你正在看的页面 渲染时的包裹关系(由内向外套娃): RootLayout → RouterLayout → [Id]Layout → Page 每个 layout 保持挂载不卸载,导航时只有 page 段会换
详情页没有再套一层只为了标题的 layout:page 保持 Server Component, generateMetadata 与页面写在一起。见动态路由专题。default.tsx 的活例子在平行路由。
loading.tsx 的本质:Next 自动把本段 page 包进 <Suspense fallback={Loading}>
app/router/conventions/loading.tsx—— 一个与 TopicSection 外形对齐的骨架屏,首次渲染本段时由它兜底决策要点:重挂载是特性不是免费;Error Boundary 在服务端没有意义
// app/template.tsx —— 与 layout 同位置、同形状,但语义不同
export default function Template({ children }: { children: React.ReactNode }) {
// 每次在本段内导航,template 都会整体重挂载:
// - 内部 state 清零
// - effect 重新执行
// - 每次进入都重新触发一次进入动画
return <div>{children}</div>;
}
// layout.tsx 则相反:导航时保持挂载,state 保留、effect 不重跑。
// 因此默认用 layout;只有「每次进入都要从头再来」的场景才用 template"use client"