Next.js App Router 页面组织:让 page 只做装配

0阅读10分钟

一个 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.tsxnotFound() 被调用时显示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>
  )
}

这段代码有几个典型问题:

  1. 顶部 'use client' 让整个页面变成客户端组件——服务端数据获取的优势全部丢失。
  2. 数据请求、状态管理、UI 渲染混在一起,无法单独测试或复用。
  3. 改一个筛选选项的文案,需要在 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 的页面组合模型可以用一张图概括:

流程图画布 · 115%
Mermaid 流程图加载中...

关键路径是:root-layout → layout → template → loading/error → page。page 是装配入口,向下拆分出 Server Component(数据获取)和 Client Component(交互处理)。Parallel Routes 则提供了同一 layout 下多面板并行渲染的能力。

Server / Client 边界的落点

页面组合中最容易出错的决策是 'use client' 放在哪里。一条原则:'use client' 尽量往下推,让 Server Component 树尽可能大。

场景归属说明
读取路由参数Server ComponentparamssearchParams 在 page 中获取
数据获取(数据库、API)Server Component在服务端直接调用,不暴露给客户端
metadata 设置Server ComponentgenerateMetadata 只能在 Server Component 中
事件处理(onClick 等)Client Component浏览器事件必须在客户端
useState / useEffectClient ComponentReact hooks 只能在客户端
浏览器 APIClient ComponentwindowlocalStorage 等只在客户端可用

如果一个页面既需要服务端数据获取又需要客户端交互,正确的做法是在 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 内部。服务端和客户端的边界在 pageClientInput 之间,而不是在整个页面之上。

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+ 行改动聚焦在对应组件
对比项layouttemplatepage
路由切换时保持挂载重新创建重新渲染
适合放什么导航、侧边栏、全局 Provider需要每次重置的动画或状态路由特有的 UI
数据获取只获取共享数据(用户信息等)不建议获取当前路由数据
客户端组件尽量避免,或推到子组件同上保持 Server Component
页面文件应该留在 page应该下沉到组件
路由参数读取
metadata / viewport 设置
服务端数据获取调用
布局组合(import + JSX 拼装)
表单校验逻辑✅ 放到表单组件
列表项渲染细节✅ 放到列表组件
弹窗状态管理✅ 放到弹窗容器组件
数据请求的具体实现✅ 放到 _lib/data.ts
筛选/排序逻辑✅ 放到对应的客户端组件

检查清单

设计阶段

  1. 页面是否只包含路由参数读取、数据获取、布局装配?
  2. 页面中是否有独立的加载区域,需要各自的 Suspense 边界?
  3. 交互状态(表单、筛选、弹窗)是否已从 page 中识别出来,准备放到独立组件?
  4. 数据获取函数是否和服务端实现放在一起,page 只调用不实现?
  5. 是否需要 Parallel Routes 来拆分多面板场景?
  6. Layout 层是否只获取了所有子路由共享的数据(如用户信息)?

实现阶段

  1. page.tsx 顶部没有 'use client',保持为 Server Component。
  2. 'use client' 指令只出现在需要交互的子组件中,且尽量往下推。
  3. 每个独立数据加载区域用 Suspense 包裹,配合 loading.tsx 或内联 fallback。
  4. 错误边界按影响范围分层:页面级用 error.tsx,区域级用内联 ErrorBoundary
  5. page 不直接 import 包含 useState 的组件到自身变成 Client Component。
  6. 数据获取函数使用独立模块(如 _lib/data.ts),不内联在 page 中。

验证阶段

  1. page 文件行数是否在 150 行以内?超过则考虑是否有 UI 逻辑该拆出去。
  2. 页面 import 的模块数量是否可控(不超过 15 个)?
  3. 能否只看 page.tsx 就说出这个路由由哪些部分组成?
  4. 新增一个筛选条件时,diff 是否只涉及对应的组件文件而非 page?
  5. 页面首屏是否能看到部分内容,而不是全白等待?
  6. 单个数据区域加载失败时,是否只影响该区域而非整个页面?

参考资料

评论 0

0 / 1000