Next.js App Router 页面组织:让 page 只做装配
一个 600 行的 page.tsx
上周我 review 一个 PR,改动只涉及一个商品列表页的筛选逻辑。打开 diff 才发现,page.tsx 已经膨胀到 600 多行——路由参数读取、数据请求、筛选状态管理、列表渲染、空状态处理、分页组件、弹窗逻辑全部堆在一个文件里。改一个筛选项的选项值,需要在这个文件里翻找 200 多行才能定位到修改点。
这不是个例。App Router 的文件系统约定让每个路由都有独立的 page.tsx,入口清晰,但也容易让页面文件变成「什么都往里塞」的容器。数据获取、状态转换、UI 渲染、交互处理、错误兜底全部写在 page 里,短期内能跑,半年后就是维护噩梦。
我花了一些时间梳理 App Router 下页面组合的模式,核心就一句话:page 负责装配和数据边界,具体实现下沉到组件和功能模块。
App Router 的页面组合模型
App Router 建立在 React Server Components 之上。组件默认在服务端执行,只有显式标记 'use client' 的组件才会在客户端运行。这条边界直接影响页面怎么组合——数据获取、服务端逻辑留在 Server Component,交互、状态、浏览器 API 交给 Client Component。
Next.js 通过文件系统约定提供了一套页面组合原语。每个路由段(route segment)可以包含这些特殊文件:
| 文件 | 职责 | 渲染时机 |
|---|---|---|
page.tsx | 渲染路由段的 UI,使路由可公开访问 | 路由匹配时 |
layout.tsx | 包裹子级,提供共享 UI(导航、侧边栏) | 路由切换时不重新渲染 |
template.tsx | 类似 layout,但每次导航都会重新挂载 | 路由切换时重新挂载 |
loading.tsx | 配合 Suspense,在子级加载时显示占位 UI | 子级 Suspense 触发时 |
error.tsx | 配合 Error Boundary,捕获子级渲染错误 | 子级抛出错误时 |
not-found.tsx | 当 notFound() 被调用时显示 | notFound() 调用时 |
这些文件不是互斥的,它们在同一目录下共同组成一个路由段的完整行为。理解它们各自的职责边界,是做好页面组合的前提。
page.tsx 的职责在官方文档中被定义为「使路由可公开访问的 UI」。它应该是一个声明式的装配点:读取路由参数、调用数据获取函数、组合子组件、设置 metadata。复杂 UI 和交互逻辑不属于这里。
案例一:数据获取和 UI 渲染混在一起
这是最常见的膨胀原因。一个商品列表页需要支持分类筛选,最直接的写法是把所有逻辑塞进 page.tsx。
❌ 坏做法:page 承担所有职责
// app/products/page.tsx — 400+ 行
'use client' // ← 因为用了 useState,整个页面变成 Client Component
import { useState } from 'react'
export default function ProductsPage() {
const [category, setCategory] = useState('all')
const [products, setProducts] = useState([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
// 数据获取逻辑直接写在页面里
useEffect(() => {
setLoading(true)
fetch(`/api/products?category=${category}&page=${page}`)
.then(res => res.json())
.then(data => setProducts(data))
.finally(() => setLoading(false))
}, [category, page])
// 筛选逻辑
const categories = ['all', 'electronics', 'books', 'clothing']
// 所有 UI 都在一个函数里
return (
<div>
<div className="flex gap-2">
{categories.map(c => (
<button
key={c}
onClick={() => setCategory(c)}
className={category === c ? 'bg-blue-500' : ''}
>
{c}
</button>
))}
</div>
{loading ? (
<div>加载中...</div>
) : products.length === 0 ? (
<div>暂无商品</div>
) : (
<div className="grid grid-cols-3 gap-4">
{products.map(p => (
<div key={p.id} className="border p-4 rounded">
<h3>{p.name}</h3>
<p>¥{p.price}</p>
</div>
))}
</div>
)}
<div className="flex justify-between mt-4">
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>
上一页
</button>
<span>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)}>下一页</button>
</div>
</div>
)
}这段代码有几个典型问题:
- 顶部
'use client'让整个页面变成客户端组件——服务端数据获取的优势全部丢失。 - 数据请求、状态管理、UI 渲染混在一起,无法单独测试或复用。
- 改一个筛选选项的文案,需要在 400 行文件里定位。
✅ 好做法:page 只做装配,职责拆分到组件
// app/products/page.tsx — 约 50 行,纯 Server Component
import { getProducts, getCategories } from './_lib/data'
import { ProductList } from './_components/product-list'
import { FilterBar } from './_components/filter-bar'
import { Pagination } from './_components/pagination'
// page 接收路由参数,调用数据获取,组合子组件
export default async function ProductsPage({
searchParams,
}: {
searchParams: Promise<{ category?: string; page?: string }>
}) {
const params = await searchParams
const category = params.category ?? 'all'
const currentPage = Number(params.page ?? 1)
// 服务端数据获取
const [products, categories] = await Promise.all([
getProducts({ category, page: currentPage }),
getCategories(),
])
return (
<div>
{/* 客户端组件,标记了 'use client',处理交互 */}
<FilterBar categories={categories} current={category} />
<ProductList products={products} />
<Pagination currentPage={currentPage} />
</div>
)
}// app/products/_components/filter-bar.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
export function FilterBar({
categories,
current,
}: {
categories: string[]
current: string
}) {
const router = useRouter()
const searchParams = useSearchParams()
const handleSelect = (category: string) => {
const params = new URLSearchParams(searchParams)
params.set('category', category)
params.set('page', '1')
router.push(`?${params.toString()}`)
}
return (
<div className="flex gap-2">
{categories.map(c => (
<button
key={c}
onClick={() => handleSelect(c)}
className={current === c ? 'bg-blue-500 text-white' : 'bg-gray-100'}
>
{c}
</button>
))}
</div>
)
}重构之后:
page.tsx从 400 行降到 50 行,一屏可读完。- 数据获取在服务端完成,首屏不需要等客户端 JS 加载。
FilterBar是独立的客户端组件,可以被其他页面复用。- 改筛选逻辑只需看
filter-bar.tsx,diff 范围可控。
什么样的文件该拆
我用的判断标准很简单:一个文件应该只回答一个问题。
| 文件 | 它回答的问题 |
|---|---|
page.tsx | 这个路由是什么、需要什么数据、由哪些部分组成 |
layout.tsx | 这组路由共享什么 UI 结构 |
_components/filter-bar.tsx | 筛选器怎么渲染、交互怎么处理 |
_components/product-list.tsx | 商品列表怎么渲染 |
_lib/data.ts | 商品数据从哪里来、怎么查询 |
如果一个文件同时在回答「数据从哪来」和「UI 怎么渲染」两个问题,就该拆。拆分不是为了抽象,是为了让每个文件的 diff 可审查。
当 page.tsx 超过 150 行、或者同时包含数据获取和超过 3 种 UI 状态(loading、empty、error、正常渲染),基本可以确定职责越界了。
案例二:Layout 嵌套时的数据透传问题
App Router 的 layout 是页面组合的另一条主线。Layout 在路由切换时不会重新渲染,适合放导航、侧边栏这类共享 UI。但 layout 嵌套过深或者数据透传不合理,会造成不必要的重渲染。
❌ 坏做法:layout 里获取所有数据透传给 children
// app/dashboard/layout.tsx
import { getUser } from '@/lib/auth'
import { getUserPreferences, getUserNotifications } from '@/lib/data'
export default async function DashboardLayout({ children }) {
const user = await getUser()
// 所有数据在 layout 层获取,透传给子级
const preferences = await getUserPreferences(user.id)
const notifications = await getUserNotifications(user.id)
return (
<div className="flex">
<Sidebar user={user} preferences={preferences} />
<main>
<TopBar notifications={notifications} />
{children}
</main>
</div>
)
}问题在于:layout 层获取了所有数据,子路由切换时 layout 虽然不重新渲染,但数据只会在 layout 层被读取一次。如果子页面需要最新的通知数据,它拿不到——因为 layout 已经渲染完了。
✅ 好做法:每层只获取自己需要的数据
// app/dashboard/layout.tsx — layout 只管结构和用户鉴权
import { getUser } from '@/lib/auth'
export default async function DashboardLayout({ children }) {
const user = await getUser()
return (
<div className="flex">
<Sidebar userId={user.id} />
<main>{children}</main>
</div>
)
}// app/dashboard/_components/sidebar.tsx — 独立的数据获取
import { getUserPreferences } from '@/lib/data'
export async function Sidebar({ userId }: { userId: string }) {
// 组件内部自己获取需要的数据
const preferences = await getUserPreferences(userId)
return (
<nav>
{/* 用 Suspense 处理加载状态 */}
<SidebarContent preferences={preferences} />
</nav>
)
}// app/dashboard/_components/top-bar.tsx
import { getUserNotifications } from '@/lib/data'
export async function TopBar({ userId }: { userId: string }) {
const notifications = await getUserNotifications(userId)
return <header>{/* 通知渲染 */}</header>
}这样每个组件自己负责自己的数据获取,layout 只关心结构。子页面切换时,TopBar 的数据不会因为 layout 缓存而过期。
案例三:Parallel Routes 与页面插槽
Parallel Routes 是 App Router 提供的另一个组合手段。它允许你在同一个 layout 里同时渲染多个页面片段(称为 slot),每个 slot 独立导航、独立加载。适合仪表盘、多面板这类需要同时展示多个独立内容区的场景。
Parallel Routes 通过文件夹命名约定 (slotName) 创建插槽。slot 内的路由变化不会影响其他 slot,也不会触发父 layout 重新渲染。这是 page 组合中常被忽略的能力。
❌ 坏做法:多面板页面用单个 page 串行获取所有数据
// app/dashboard/page.tsx — 所有面板的数据都在这获取
export default async function DashboardPage() {
// 串行获取三个面板的数据
const analytics = await getAnalytics()
const recentOrders = await getRecentOrders()
const notifications = await getNotifications()
return (
<div className="grid grid-cols-3">
<AnalyticsPanel data={analytics} />
<OrdersPanel data={recentOrders} />
<NotificationsPanel data={notifications} />
</div>
)
}三个面板的数据串行获取,最慢的那个决定了整个页面的渲染时间。任何一个面板的数据获取失败,整个页面都会走进 error boundary。
✅ 好做法:用 Parallel Routes 拆分独立面板
app/dashboard/
page.tsx ← 只负责主内容区
@analytics/
page.tsx ← 分析面板,独立路由
@notifications/
page.tsx ← 通知面板,独立路由
layout.tsx ← 组合所有 slot
// app/dashboard/layout.tsx — 组合 slot
import { Suspense } from 'react'
export default function DashboardLayout({
children,
analytics,
notifications,
}: {
children: React.ReactNode
analytics: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="grid grid-cols-3 gap-4">
{/* 每个 slot 独立 Suspense,互不阻塞 */}
<Suspense fallback={<div className="animate-pulse h-48 bg-gray-100" />}>
{analytics}
</Suspense>
<div>{children}</div>
<Suspense fallback={<div className="animate-pulse h-48 bg-gray-100" />}>
{notifications}
</Suspense>
</div>
)
}// app/dashboard/page.tsx — 只负责自己的内容
import { getRecentOrders } from './_lib/data'
import { OrdersPanel } from './_components/orders-panel'
export default async function DashboardPage() {
const orders = await getRecentOrders()
return <OrdersPanel orders={orders} />
}// app/dashboard/@analytics/page.tsx — 独立获取分析数据
import { getAnalytics } from './_lib/data'
import { AnalyticsPanel } from './_components/analytics-panel'
export default async function AnalyticsSlot() {
const data = await getAnalytics()
return <AnalyticsPanel data={data} />
}每个 slot 独立加载、独立出错、独立渲染 loading 状态。page.tsx 不再需要关心其他面板的数据,它只需要回答「主内容区展示什么」。
App Router 页面组合全景
把这些文件约定放在一起看,App Router 的页面组合模型可以用一张图概括:
关键路径是:root-layout → layout → template → loading/error → page。page 是装配入口,向下拆分出 Server Component(数据获取)和 Client Component(交互处理)。Parallel Routes 则提供了同一 layout 下多面板并行渲染的能力。
Server / Client 边界的落点
页面组合中最容易出错的决策是 'use client' 放在哪里。一条原则:'use client' 尽量往下推,让 Server Component 树尽可能大。
| 场景 | 归属 | 说明 |
|---|---|---|
| 读取路由参数 | Server Component | params 和 searchParams 在 page 中获取 |
| 数据获取(数据库、API) | Server Component | 在服务端直接调用,不暴露给客户端 |
| metadata 设置 | Server Component | generateMetadata 只能在 Server Component 中 |
| 事件处理(onClick 等) | Client Component | 浏览器事件必须在客户端 |
| useState / useEffect | Client Component | React hooks 只能在客户端 |
| 浏览器 API | Client Component | window、localStorage 等只在客户端可用 |
如果一个页面既需要服务端数据获取又需要客户端交互,正确的做法是在 page 和交互组件之间划一条线:
// page.tsx — 保持为 Server Component
import { ClientInput } from './_components/client-input'
import { getData } from './_lib/data'
export default async function Page() {
const data = await getData() // 服务端获取
return (
<div>
<h1>{data.title}</h1>
<ClientInput initialData={data} /> {/* 客户端组件 */}
</div>
)
}// _components/client-input.tsx
'use client'
import { useState } from 'react'
export function ClientInput({ initialData }) {
const [value, setValue] = useState(initialData.value)
// ... 交互逻辑
}page.tsx 没有 'use client',它是 Server Component。'use client' 只出现在 ClientInput 内部。服务端和客户端的边界在 page 和 ClientInput 之间,而不是在整个页面之上。
Loading 和 Error 边界也是组合的一部分
很多项目把 loading.tsx 写成一个全局的 loading 状态,然后整个页面白屏等待。这其实违背了 App Router 的设计意图。
loading.tsx 配合的是 React Suspense。如果一个页面有多个独立的数据加载区域,应该让每个区域有自己的 Suspense 边界,而不是用一个大的 loading 覆盖全部:
// app/dashboard/page.tsx — 每个区域独立 Suspense
import { Suspense } from 'react'
import { StatsCard } from './_components/stats-card'
import { RecentActivity } from './_components/recent-activity'
import { Chart } from './_components/chart'
export default function DashboardPage() {
return (
<div className="grid gap-4">
{/* 每个区域独立加载,谁先返回谁先渲染 */}
<Suspense fallback={<div className="h-32 animate-pulse bg-gray-100" />}>
<StatsCard />
</Suspense>
<Suspense fallback={<div className="h-64 animate-pulse bg-gray-100" />}>
<Chart />
</Suspense>
<Suspense fallback={<div className="h-48 animate-pulse bg-gray-100" />}>
<RecentActivity />
</Suspense>
</div>
)
}用户能更快看到页面内容(骨架屏先出来),而不是面对一个全白的页面等所有数据加载完。单个区域的错误也不会导致整个页面崩溃。
对比总结
| 维度 | 坏的 page | 好的 page |
|---|---|---|
| 行数 | 300-600+ 行 | 50-120 行 |
| 职责 | 数据 + UI + 交互 + 状态 | 路由参数 + 数据获取 + 组件装配 |
'use client' | 页面顶部 | 下沉到子组件 |
| 可测试性 | 需要 mock 路由、DOM、API | 子组件可独立单测 |
| diff 范围 | 改一个筛选项涉及 200+ 行 | 改动聚焦在对应组件 |
| 对比项 | layout | template | page |
|---|---|---|---|
| 路由切换时 | 保持挂载 | 重新创建 | 重新渲染 |
| 适合放什么 | 导航、侧边栏、全局 Provider | 需要每次重置的动画或状态 | 路由特有的 UI |
| 数据获取 | 只获取共享数据(用户信息等) | 不建议 | 获取当前路由数据 |
| 客户端组件 | 尽量避免,或推到子组件 | 同上 | 保持 Server Component |
| 页面文件 | 应该留在 page | 应该下沉到组件 |
|---|---|---|
| 路由参数读取 | ✅ | — |
| metadata / viewport 设置 | ✅ | — |
| 服务端数据获取调用 | ✅ | — |
| 布局组合(import + JSX 拼装) | ✅ | — |
| 表单校验逻辑 | — | ✅ 放到表单组件 |
| 列表项渲染细节 | — | ✅ 放到列表组件 |
| 弹窗状态管理 | — | ✅ 放到弹窗容器组件 |
| 数据请求的具体实现 | — | ✅ 放到 _lib/data.ts |
| 筛选/排序逻辑 | — | ✅ 放到对应的客户端组件 |
检查清单
设计阶段
- 页面是否只包含路由参数读取、数据获取、布局装配?
- 页面中是否有独立的加载区域,需要各自的 Suspense 边界?
- 交互状态(表单、筛选、弹窗)是否已从 page 中识别出来,准备放到独立组件?
- 数据获取函数是否和服务端实现放在一起,page 只调用不实现?
- 是否需要 Parallel Routes 来拆分多面板场景?
- Layout 层是否只获取了所有子路由共享的数据(如用户信息)?
实现阶段
page.tsx顶部没有'use client',保持为 Server Component。'use client'指令只出现在需要交互的子组件中,且尽量往下推。- 每个独立数据加载区域用
Suspense包裹,配合loading.tsx或内联 fallback。 - 错误边界按影响范围分层:页面级用
error.tsx,区域级用内联ErrorBoundary。 - page 不直接 import 包含
useState的组件到自身变成 Client Component。 - 数据获取函数使用独立模块(如
_lib/data.ts),不内联在 page 中。
验证阶段
- page 文件行数是否在 150 行以内?超过则考虑是否有 UI 逻辑该拆出去。
- 页面 import 的模块数量是否可控(不超过 15 个)?
- 能否只看
page.tsx就说出这个路由由哪些部分组成? - 新增一个筛选条件时,diff 是否只涉及对应的组件文件而非 page?
- 页面首屏是否能看到部分内容,而不是全白等待?
- 单个数据区域加载失败时,是否只影响该区域而非整个页面?
参考资料
- Layouts and Pages — Next.js Official Docs — 官方文档对 layouts、pages、templates 的文件约定和组合模型有完整说明。
- Parallel Routes — Next.js Official Docs — Parallel Routes 的文件约定、slot 机制和使用场景。
- Intercepting Routes — Next.js Official Docs — 拦截路由的
(.)语法和弹窗等场景的实现方式。 - How Are People Structuring Larger Next.js Apps with App Router? — 社区对大型 App Router 项目文件组织的讨论,「keep the page as a thin layer」是高频共识。
- 5 Lessons Learned From Taking Next.js App Router to Production — Inngest 团队的生产实践,涉及 streaming、缓存和 URL search params 在 layout 中的使用。
- Next.js App Router: Common Mistakes and How to Fix Them — 常见 App Router 误用汇总,包括 page 过重、Server/Client 边界错位等问题。
- React Server Components — React 官方文档对 Server Components 的设计动机和使用约束的说明。