上一节里,我们已经能从 app 文件树推导 URL,也认识了 page.tsx、layout.tsx、loading.tsx 这些特殊文件。可一旦真正开始写项目,问题很快会从“这个地址对应哪个文件”变成另外三个:哪部分界面只属于当前地址?哪部分应该在页面切换时留下来?又有哪部分必须在切换后主动清空?
这三个问题,刚好对应 page、layout 和 template。你可以先记住一个够用的判断:页面负责当前 URL 的独有内容,布局负责多个页面共享且需要保留的外壳,模板负责有意重置的子树。 后面遇到复杂目录时,我们仍然回到这条判断,不靠文件名猜生命周期。
这一节会贯穿一个“团队知识库”案例。它有工作台首页 /workspace、项目详情 /workspace/projects/[slug] 和设置页 /workspace/settings。我们会给它加上共享侧栏、当前菜单、项目筛选、动态标题、加载骨架和错误恢复,最后再检查每一层到底由谁控制。

先按“独有、保留、重置”分清职责,再决定文件应该放在哪一层。
本节只讨论 App Router。若资料里出现 pages/_app.tsx、getLayout、getStaticProps 或 getServerSideProps,那通常是 Pages Router 的做法。两套路由器可以在迁移期共存,但不要把它们的文件约定混到同一段代码里。
理解页面与布局,最容易走偏的地方,是只盯着 URL。URL 能告诉我们访问了哪个页面,却没有把外层布局、模板和边界画出来。实际渲染的是一棵从根布局向内包裹的组件树。
先看知识库的目录:
app/
├── layout.tsx
├── page.tsx
└── workspace/
├── error.tsx
├── layout.tsx
├── loading.tsx
├── page.tsx
├── settings/
│ └── page.tsx
└── projects/
└── [slug]/
├── page.tsx
└── template.tsx访问 /workspace/projects/atlas 时,最终结构可以粗略理解成:
RootLayout
└── WorkspaceLayout
└── WorkspaceErrorBoundary
└── WorkspaceLoadingBoundary
└── ProjectTemplate
└── ProjectPage这里的顺序不是装饰。某个组件是否会保留、某个异常会被哪一层接住、某段等待会显示哪个骨架,都取决于它位于树的哪一侧。

文件夹给出路由层级,特殊文件把同一层组装成有明确边界的组件树。
文件夹存在,不代表浏览器一定能打开它。app/workspace/reports/ 里即使放了很多普通组件,只要没有 page.tsx 或 route.ts,/workspace/reports 就不是公开入口。
page.tsx 必须默认导出 React 组件,而且它总是当前路由子树最里面的叶子:
// app/workspace/page.tsx
export default function WorkspacePage() {
return (
<section>
<h1>工作台概览</h1>
<p>这里显示最近访问的项目。</p>
</section>
)
}页面默认是 Server Component。它既可以在构建时生成,也可以在请求时运行;“Server Component”说的是组件运行与打包边界,不等于每个页面都必然进行传统的逐请求 SSR。
layout.tsx 接收 children。这个 children 可能是同层页面,也可能是更深一层布局及其页面:
// app/workspace/layout.tsx
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="workspace-shell">
<aside>项目导航</aside>
<main>{children}</main>
</div>
)
}布局不会单独创造 URL。app/workspace/layout.tsx 服务 /workspace 以及它下面的项目页、设置页;能公开访问哪些地址,仍由各层的 page.tsx 决定。
同一个 route segment 中,可以用下面这条简化顺序定位边界:
layout
└── template
└── error
└── loading
└── not-found
└── page 或下一层 layout后面的章节会逐一放大这些边界。现在先抓住一个原则:边界只能处理它里面的内容,不能倒过来处理外面的组件。 所以同层 error.tsx 接不住同层 layout.tsx 自己抛出的异常,同层 loading.tsx 也不能为同层布局里的慢请求显示 fallback。
下面的实验把文件树、公开 URL 和组件树放到同一块画布里。你可以先移除 page.tsx,再切换不同层级的特殊文件,观察三者如何变化。
页面最直接的职责,是拿到当前 URL 对应的输入,再返回独有内容。在知识库里,项目 slug 来自路径,列表视图和标签来自查询字符串。它们都属于当前页面,而不属于长期保留的共享布局。
动态目录 [slug] 会把路径中的值交给页面。当前 Next.js 版本里,params 是 Promise,所以服务器页面要先 await:
// app/workspace/projects/[slug]/page.tsx
export default async function ProjectPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<section>
<h1>项目:{slug}</h1>
</section
如果项目已经运行过 next dev、next build 或 next typegen,还可以使用全局生成的 PageProps,减少手写类型:
// app/workspace/projects/[slug]/page.tsx
export default async function ProjectPage(
props: PageProps<'/workspace/projects/[slug]'>
) {
const { slug } = await props.params
return <h1>项目:{slug}</h1>
}PageProps 不需要 import。它的严格键名来自真实路由结构,所以在目录改名后重新生成类型,能更早发现页面仍在读取旧参数的问题。
假设访问:
/workspace/projects/atlas?view=list&tag=react&tag=nextjs页面可以同时读取 params 和 searchParams:
// app/workspace/projects/[slug]/page.tsx
type ProjectPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<{
view?: string
tag?: string | string[]
}>
}
export default async function ProjectPage({
params,
searchParams,
}:
这里有两个细节值得停一下。searchParams 也是 Promise;它 resolve 后得到普通对象,不是 URLSearchParams 实例,因此没有 .get() 方法。重复查询键可能得到数组,面向真实用户输入时不要直接假设它永远是字符串。

路径参数通常确定“是哪一个资源”,查询参数通常描述“怎样查看这批内容”。
查询字符串来自这次访问,在构建阶段无法提前知道。页面使用 searchParams 后,会进入请求时动态渲染。这里的“动态渲染”说的是生成时机,不是说 URL 必须包含 [slug]。
“动态路由段”和“动态渲染”是两件事。[slug] 描述一组 URL 的匹配方式;读取 searchParams、cookies() 等请求时信息会影响页面何时生成。一个带 [slug] 的页面可以预生成,一个没有方括号的页面也可能按请求动态渲染。
如果查询参数要参与服务器取数,比如分页查询数据库,就在页面使用 searchParams。如果数据已经通过 props 交给浏览器,只想在客户端切换显示方式,可以把很小的交互组件标为 'use client',在里面使用 useSearchParams()。
Client Component 不能把组件函数写成 async,但 React 的 use() 可以读取框架传入的 Promise:
// app/demo/[slug]/page.tsx
'use client'
import { use } from 'react'
export default function DemoPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = use(params)
return <p>当前 slug:{slug}</p>
}大多数页面不需要为了读取参数变成 Client Component。把页面留在服务器端,再把按钮、输入框等需要交互的部分拆成客户端小组件,通常更清楚,也能减少发送到浏览器的 JavaScript。
当多个页面都要显示同一套导航、侧栏或上下文时,把它们复制到每个 page.tsx 里当然能跑,但页面一多就会失控。布局把这些结构放在共同祖先上,而且在站内软导航时保留共享 UI。
app 必须有最外层根布局,除非你有意使用多个根布局。普通单根应用通常写成:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: '团队知识库',
template: '%s | 团队知识库',
},
description: '团队项目与技术文档中心',
}
export default function RootLayout({
children,
}: {
children: React
根布局必须渲染 <html> 与 <body>。标题、描述等普通 head 信息不要手写进 <head>,交给 Metadata API 处理,Next.js 会负责去重、流式 metadata 和正确的标签位置。
工作台需要侧栏,但公开首页不需要。我们把侧栏放到 app/workspace/layout.tsx:
// app/workspace/layout.tsx
import Link from 'next/link'
import { ActiveWorkspaceNav } from './active-workspace-nav'
import { WorkspaceCounter } from './workspace-counter'
export default function WorkspaceLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="workspace-shell">
<
根布局先包住工作台布局,工作台布局再包住当前页面。这样 /workspace、项目页和设置页共享侧栏,站点首页仍然保持自己的结构。
布局位于动态 segment 中时,也能读取从根到当前层已经确定的异步 params:
// app/workspace/projects/[slug]/layout.tsx
export default async function ProjectLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return (
<section aria-label
Layout 的 params 只包括根到当前布局这一段已经出现的动态参数,不包括它下面尚未进入的子 segment。它同样是 Promise,应使用 await 或在客户端边界里使用 React use()。
布局默认也是 Server Component。若侧栏里有计数器或折叠菜单,只把交互部分拆出去:
// app/workspace/workspace-counter.tsx
'use client'
import { useState } from 'react'
export function WorkspaceCounter() {
const [count, setCount] = useState(0)
return (
<button type="button" onClick={() => setCount((value) => value + 1)}>
通过 <Link> 在同一个工作台子树内导航时,共享布局仍在,计数器组件也不会因为每次页面变化而重新挂载。根布局继续留在服务器端,因此还能导出 metadata、读取服务器数据,并把客户端边界控制在真正需要交互的区域。
官方文档常说布局在导航时保留状态、保持交互并且不重新渲染。这里说的是 App Router 的客户端软导航:浏览器已经拥有共享布局,Next.js 只请求并替换改变的路由部分。
不要把这句话扩大成“布局永远只执行一次”。下面三种场景不同:
普通 <a href="/workspace/settings"> 会走浏览器完整导航。若你一边用 <a>,一边期待布局里的计数器保持,就已经把两种导航模型混在了一起。
共享布局在软导航时被复用。如果服务器布局直接拿到旧的查询参数或 pathname,它很快会和浏览器地址不一致,所以 Layout API 根本不提供 searchParams prop,也不支持 Server Component 直接读取当前 URL pathname。
需要当前菜单高亮时,保留服务器布局,再嵌一个客户端导航:
// app/workspace/active-workspace-nav.tsx
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const items = [
{ href: '/workspace', label: '概览' },
{ href: '/workspace/settings', label: '设置' },
]
export function ActiveWorkspaceNav() {
const pathname = usePathname()
return (
<
查询条件同理:服务器取数交给 page 的 searchParams,共享导航里若只需展示最新 query,就使用很小的 useSearchParams() Client Component。若这条路由原本可以预渲染,应在它外面放置 <Suspense> 并提供稳定 fallback,避免读取浏览器查询参数把更大的静态区域一起推到客户端渲染。
布局可以调用异步 cookies() 与 headers():
// app/workspace/layout.tsx
import { cookies } from 'next/headers'
export default async function WorkspaceLayout({
children,
}: {
children: React.ReactNode
}) {
const cookieStore = await cookies()
const density = cookieStore.get('density')?.value ?? 'comfortable'
return <
这些值只能在请求时知道。更关键的是,同层 loading.tsx 位于 layout 里面,不能替 layout 自己的运行时读取显示 fallback。如果布局的慢数据会阻塞导航,优先把慢读取下移到页面,或把布局中真正动态的区域拆进独立 <Suspense> 边界。
下面的模拟器专门对比软导航、刷新、动态参数变化和查询参数变化。先把布局计数器加到 3,再依次切换路线,观察哪些状态被保留。
模板和布局都会包住 children,所以代码外形非常像。真正的区别在导航行为:布局尽量保持,模板会在它所属层级的 segment key 改变时重新挂载。

模板不是“另一个布局”,它是在明确的 segment 变化上重建内部子树。
框架会给模板一个基于当前 segment 的 key。结构可以粗略写成:
<Layout>
<Template key={segmentKey}>
{children}
</Template>
</Layout>项目详情希望在从 /projects/atlas 切到 /projects/orion 时清空临时草稿,可以在动态段里加模板:
// app/workspace/projects/[slug]/template.tsx
import { ProjectDraftShell } from './project-draft-shell'
export default function ProjectTemplate({
children,
}: {
children: React.ReactNode
}) {
return <ProjectDraftShell>{children}</ProjectDraftShell>
}把交互状态留在客户端小组件中:
// app/workspace/projects/[slug]/project-draft-shell.tsx
'use client'
import { useEffect, useState } from 'react'
export function ProjectDraftShell({
children,
}: {
children: React.ReactNode
}) {
const [draft, setDraft] = useState('')
useEffect(() => {
console.log(
当 [slug] 从 atlas 变成 orion,这一层模板拿到新 key,内部状态回到初始值,空依赖的 Effect 也重新同步。我们不需要监听模糊的 [children] 变化来模拟路由动画。
判断模板时要看它所在的层级,而不是只看浏览器地址有没有变化:
例如模板位于 app/workspace/projects/[slug]/template.tsx:
模板适合“页面变化就应该重新开始”的体验,例如切换项目后清空未保存的筛选草稿、在对应 segment 变化时重新同步 Effect,或者希望某个布局内 Suspense fallback 随相关导航再次显示。
如果你真正想要的是一直保留的导航、播放器或展开状态,应该放 layout;如果只是页面自己的数据和 UI,就留在 page。不要因为模板看起来更灵活,就给每层目录都加一个。
有些项目希望营销页、登录页和工作台使用不同外壳,但 URL 里又不想出现 marketing、auth 这类内部组织名。圆括号 Route Group 正是为这种组织问题准备的。
app/
├── layout.tsx
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx
│ └── pricing/
│ └── page.tsx
└── (workspace)/
└── workspace/
├── layout.tsx
└── page.tsx这里 (marketing) 不会出现在地址里:
app/(marketing)/page.tsx → /
app/(marketing)/pricing/page.tsx → /pricing
app/(workspace)/workspace/page.tsx → /workspaceRoute Group 的用途是按团队、功能或布局关系组织路由,也可以让一部分路由共享布局、另一部分绕开它。
如果移除顶层 app/layout.tsx,可以让不同分组各自拥有根布局:
app/
├── (public)/
│ ├── layout.tsx # 根布局 A,包含 html 和 body
│ ├── page.tsx # 首页必须落在某个根布局中
│ └── pricing/
│ └── page.tsx
└── (workspace)/
├── layout.tsx # 根布局 B,也包含 html 和 body
└── workspace/
└── page.tsx每个没有上层 layout 的布局都是根布局,都必须提供 <html> 与 <body>。从 /pricing 导航到 /workspace 时,会跨越根布局并触发完整页面加载,而不是普通 App Router 软导航。

分组解决代码组织和布局边界;根布局边界决定是否还能进行同一文档内的软导航。
下面的目录会冲突:
app/(public)/about/page.tsx → /about
app/(workspace)/about/page.tsx → /about圆括号被 URL 忽略后,两棵目录都想接管 /about。分组名不同并不能让冲突消失。
(mobile) 和 (desktop) 不会自动根据设备选择布局。如果两个分组都定义相同页面,它们反而会解析到相同 URL 并产生冲突。普通设备适配优先使用响应式 CSS;确实需要按请求信息分流时,也要设计明确的 URL、重写或服务器判断,不能把 Route Group 当设备探测器。
页面和布局不只决定正文,还决定浏览器标签、搜索摘要和分享信息。App Router 把这些内容纳入路由层级,让默认值从外向内继承,再由更具体的页面覆盖。
根布局里可以定义全站默认值和标题模板:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: '团队知识库',
template: '%s | 团队知识库',
},
description: '团队项目与技术文档中心',
}普通固定页面只需要给出自己的标题:
// app/workspace/settings/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '工作台设置',
}
export default function SettingsPage() {
return <h1>工作台设置</h1>
}最终标题是“工作台设置 | 团队知识库”。metadata 和 generateMetadata 只能从 Server Component 页面或布局导出,因此不要把同一个文件标成 'use client'。
标题模板有一个很容易误判的边界:它作用于子 segment,不作用于定义模板的同层页面。app/layout.tsx 的模板能影响 app/workspace/page.tsx;若在 app/workspace/layout.tsx 新定义模板,它不会套用到同一个 app/workspace/page.tsx 的标题,只影响更深的子路由。
创建 title.template 时还必须提供 title.default。若某个页面要完全忽略祖先模板,可以使用 title.absolute。

metadata 沿 route segment 从外向内解析,越靠近页面的信息越具体。
项目名称要从数据源读取,可以让动态页面生成 metadata:
// app/workspace/projects/[slug]/page.tsx
import type { Metadata } from 'next'
import { getProject } from '@/app/lib/projects'
type Props = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export async function
generateMetadata 自己也是渲染的一部分。能够预生成且没有引入请求时行为时,metadata 会进入初始 HTML;动态页面中,Next.js 还可以在适合普通浏览器时流式发送 metadata。
上面的两个调用不应该重复查询同一条数据库记录。把 React cache 创建的函数放到共享模块:
// app/lib/projects.ts
import 'server-only'
import { cache } from 'react'
import { db } from './db'
export const getProject = cache(async (slug: string) => {
return db.project.findUniqueOrThrow({
where: { slug },
})
})同一次服务器渲染中,page 和 generateMetadata 使用相同函数、相同字符串参数,可以共享结果。React 会在不同服务器请求之间使这类 memoization 失效,所以它不是跨请求持久缓存,也不能替代数据库缓存或 Next.js 的数据缓存策略。
若项目 slug 在构建时已知,可以预生成一组页面:
// app/workspace/projects/[slug]/page.tsx
export const dynamicParams = false
export async function generateStaticParams() {
const projects = await getPublishedProjects()
return projects.map((project) => ({
slug: project.slug,
}))
}generateStaticParams 必须返回对象数组。dynamicParams = false 表示未列入数组的 slug 不再按需生成,而是返回 404。不要为了“性能优化”机械添加它:参数数量很大、内容频繁变化或请求个性化时,按需渲染可能更合理。
Next.js 16 启用 Cache Components 后,对旧式 dynamic、revalidate、fetchCache 等 route segment config 有不同规则。本项目当前未启用它,但本节也不把 dynamic = 'force-dynamic' 当默认开关;真正需要请求时渲染时,应由实际数据和请求 API 决定。
页面开始取数据后,用户会遇到等待;数据源或代码出问题后,用户会遇到异常。App Router 用 route segment 级别的 loading.tsx 和 error.tsx 表达这两种状态,关键仍然是边界放在哪里。

同层 layout 在两个边界之外,page 和更深子树才在边界之内。
项目路由可以提供轻量骨架:
// app/workspace/projects/[slug]/loading.tsx
export default function LoadingProject() {
return (
<section aria-busy="true" aria-label="正在加载项目">
<div className="skeleton title" />
<div className="skeleton line" />
<div className="skeleton line" />
</section>
)
}框架会用 Suspense boundary 包住同层 page 和更深子树。fallback 可以被预取,让动态路由导航更快获得反馈;等待期间,共享 layout 保持可交互,用户也可以中断当前导航去另一个地址。
loading.tsx 不接收 props,内容应轻量、稳定。它不包同层 layout、template 或 error,所以把慢查询放在同层 layout 后,不能指望这个 loading 文件替它兜底。
当前项目的 Next.js 16.1.6 错误边界使用 reset() 尝试重新渲染该段:
// app/workspace/projects/[slug]/error.tsx
'use client'
import { useEffect } from 'react'
export default function ProjectError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error
生产环境中,Server Component 的原始错误消息不会完整发到客户端,以免泄露敏感信息;digest 可用于把用户看到的编号和服务器日志对应起来。
同层顺序是 layout → template → error → loading → page。因此 app/workspace/error.tsx 能接住工作台页面和更深布局里的未捕获渲染异常,却接不住 app/workspace/layout.tsx 本身抛出的异常。后者会继续向父级 error boundary 冒泡。
如果根布局或根模板出错,要使用 app/global-error.tsx。它会替换根布局,所以必须自行返回 <html> 与 <body>,也要自行引入错误界面需要的全局样式。
找不到项目、表单校验失败、接口返回“权限不足”,都可能是业务流程中的正常分支。资源不存在时调用 notFound() 并提供 not-found.tsx;表单错误作为结果返回;请求失败也可以在页面里给出明确状态。
error.tsx 更适合未预期的异常。把所有失败都 throw,会让用户失去具体上下文,也让错误监控充满本来可以正常处理的业务结果。
慢网络下,预取可能还没完成。useLinkStatus 可以在具体 <Link> 内显示小提示:
// app/workspace/project-link.tsx
'use client'
import Link, { useLinkStatus } from 'next/link'
function PendingHint() {
const { pending } = useLinkStatus()
return pending ? <span aria-live="polite">正在打开…</span> : null
}
export function ProjectLink({ slug }: { slug:
优先给动态 route segment 配置 loading.tsx,因为它能表达整段内容的等待状态。链接级 pending 适合预取未完成、关闭预取或需要即时点击反馈的场景。
下面的实验室可以把延迟或异常注入 page、嵌套 layout 和同层 layout。观察边界高亮后,再尝试预测下一次会由谁接管。
页面、布局和模板分清后,很多“状态放哪里”的问题会变成一张生命周期对照表。
在 layout 里隐藏“管理按钮”可以改善界面,但不能保护敏感数据。由于共享 layout 在部分渲染导航时不会针对每次 route change 重新执行,把唯一的认证或授权检查放在那里,可能得到陈旧会话,也挡不住 Server Actions、Route Handlers 或直接数据访问。
推荐把验证函数放在服务器专用的数据访问层,并让每个敏感查询靠近数据源调用它:
// app/lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export const verifySession = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = token ?
页面或数据函数在读取私有项目之前调用 verifySession()。布局可以用同一函数显示头像,但那不是唯一防线;每个敏感 Server Action 也要重新校验权限。
在顶层组件判断无权限后 return null,只是不显示那一块 UI。Next.js 应用有多个入口,嵌套 route segment 和服务器操作仍可能被直接调用。安全判断必须与数据和动作绑定,不能只与视觉外壳绑定。
组件状态回答“界面现在长什么样”,会话与授权回答“这次数据访问是否允许”。二者可以在同一个页面相遇,但不能用保留状态的 layout 替代服务器端权限校验。
这一组问题在真实项目里很常见。每一条都不是语法错误,却会让应用表现和预期相反。
错误做法:
<a href="/workspace/settings">设置</a>站内页面优先改用:
import Link from 'next/link'
<Link href="/workspace/settings">设置</Link>Link 提供预取和客户端导航,才能发挥共享布局保留与局部替换。下载文件、外部网站或确实需要浏览器完整加载时,普通 <a> 仍然合适。
错误思路:共享 layout 接收最新查询参数并直接渲染筛选标签。软导航后它可能成为陈旧值,所以 API 不提供这个 prop。
改法:服务器查询放 page;共享 UI 只需显示地址状态时,嵌入 useSearchParams() Client Component。
为了一个主题按钮把 app/layout.tsx 变成 Client Component,随后又想导出 metadata、读取服务器资源,会立刻遇到边界冲突。保持根布局为服务器组件,把主题按钮或 Provider 单独拆成客户端组件。
模板的 key 跟 route segment 层级有关,不跟所有 URL 字符串变化一一对应。query 改变不会自动重置模板;只有更深 segment 改变,也不会重挂载高层模板。
共享布局不会在每次部分导航时重新检查。把权限验证放到 DAL、页面、叶子组件和服务器操作,布局只负责共享外壳和必要的展示数据。
loading.tsx 在同层 layout 里面。把慢请求下移到 page,或在布局的动态区域手动放置 Suspense boundary,才能及时显示反馈。
error.tsx 同样位于同层 layout 内部。异常会向父级冒泡;根布局错误交给 global-error.tsx。
React cache 用于同一次 Server Component 渲染中共享结果,跨服务器请求会失效。它能避免 page 与 metadata 的重复查询,却不是 CDN、数据库缓存或持久 KV。
旧教程可能还写 params.slug。当前代码应把 params、页面 searchParams 视为 Promise,服务器组件使用 await,客户端组件在必要时使用 React use()。
下面有两个调试题。先不要展开答案,试着用“独有、保留、重置”和组件层级自己定位。
工作台计数器放在 layout 内,但导航链接写成普通 <a>。点击设置页后,计数器回到 0。应该改哪里?
开发者想在服务器 layout 中读取 searchParams.tag。从 ?tag=react 导航到 ?tag=nextjs 后,侧栏仍显示旧值。应该怎样拆分?
现在把本节内容落到一棵完整目录上。目标不是把所有特殊文件都摆进去,而是让每个文件都能回答一个明确问题。
你要完成这些行为:
/workspace、/workspace/settings 和 /workspace/projects/[slug] 共享工作台侧栏。<Link> 导航时,侧栏计数器保持状态。await params 和 searchParams。?tab= 时,模板草稿不因模板重挂载而自动清空。generateMetadata 生成,并继承根布局标题模板。可以按下面的顺序实现:
先建立 app/layout.tsx,只放全站 metadata、html、body 和真正全局的 Provider。根布局保持 Server Component。
在 app/workspace/layout.tsx 放共享侧栏和 children。把计数器、当前菜单分别拆成客户端小组件,站内链接全部使用 Link。
创建 app/workspace/projects/[slug]/page.tsx,先 await 路径参数,再规范化 searchParams 中可能为字符串或数组的值。
先自己画目录,再展开一种参考结构。
页面和布局的文件名不难记,真正需要带走的是它们各自的生命周期边界。你现在应该能用下面这组问题检查自己的目录:
<Link> 软导航,还是刷新、普通 <a>、跨根布局的完整加载?状态结果会不同。如果这七个问题都能回答,复杂目录也只是多套相同规则的嵌套。下一节再进入 Server Component 与 Client Component 时,我们会继续沿用这里的拆分方式:服务器组件负责数据与静态结构,客户端边界只包住确实需要浏览器状态和事件的部分。
在 [slug] 层加入 template.tsx,把需要随项目变化清空的草稿组件放进去。分别测试 slug 变化和 query 变化。
为项目数据建立服务器专用的 getProject,用同一个缓存函数服务 page 与 generateMetadata,并在函数附近执行会话和权限校验。
最后加入 loading.tsx 与 error.tsx。分别制造慢请求、页面异常和同层布局异常,确认每个边界的真实作用范围。