Next.js 16 App Router 实战:服务端组件与流式渲染

2023 年 Next.js 正式引入 App Router,至今已迭代至第 16 个主版本。简历上写"熟悉 Next.js App Router"的前端工程师数量急剧增加,但真正在生产项目中跑通过的仍然是少数。Pages Router 与 App Router 的混用、Server Component 与 Client Component 的边界模糊、服务端动作的安全边界------这些问题在官方文档里被分散在多个章节,实际落地时稍不留神就会踩坑。

本文从实际项目出发,系统梳理 App Router 的核心概念、常见错误模式,以及从 Pages Router 迁移的决策路径。不讲"经验法则",只讲可验证的技术事实。


01 App Router 是什么:文件系统的路由哲学

App Router 是 Next.js 13 开始引入的全新路由架构,完全基于文件系统约定:项目根目录下的 app/ 文件夹即为路由起点,每个子文件夹对应一个路由段。

ini 复制代码
app/
├── layout.tsx        # 根布局(所有页面共享)
├── page.tsx          # 根页面(/)
├── about/
│   └── page.tsx      # /about
├── blog/
│   ├── layout.tsx    # /blog 专属布局
│   ├── page.tsx      # /blog
│   └── [slug]/
│       └── page.tsx  # /blog/:slug
└── api/
    └── route.ts      # API Route Handler(/api/*)

这种约定的核心变化在于:路由结构与 UI 布局结构一一对应app/blog/layout.tsx 定义的布局会自动包裹 app/blog/ 下的所有页面,无需手动传递 children prop。这解决了 Pages Router 时代 _app.tsx 过度中心化的问题------每个子路由现在可以独立定义自己的布局层级。

文件命名有几条关键约定:带圆括号的目录名 (marketing) 表示该段不参与路由(仅用于组织文件),下划线开头的目录名 _components 表示该目录完全不参与路由。动态路由段用方括号包裹,[slug] 捕获字符串段,[...catchall] 捕获剩余路径,[[...optional]] 则表示可选段。

中间件文件 middleware.ts 位于项目根目录,逻辑运行在所有路由之前,常用于身份验证检查、国际化重定向和 A/B 测试流量分配。相比 Pages Router 的 getServerSideProps 在页面级处理请求,中间件的优势是可以统一处理全局路由逻辑而不需要在每个页面重复代码。


02 服务端组件:默认行为与边界控制

App Router 最深远的变革是默认服务端组件(React Server Components, RSC)app/ 目录下的所有组件默认运行在服务端,只有明确声明 'use client' 的文件才进入客户端 bundle。

这一区别的直接影响是:

服务端组件 可以直接访问后端资源(数据库、文件系统、环境变量),打包时不进入客户端 JavaScript 体积,但不能使用 hooks、浏览器 API 和事件处理器

客户端组件则与传统 React 组件无异,可以 useState、useEffect、绑定事件,但失去了直接访问服务端资源的能力。

tsx 复制代码
// app/dashboard/page.tsx --- 服务端组件(默认,无需声明)
import { db } from '@/lib/db'

export default async function DashboardPage() {
  // 直接查询数据库,不经过 API 层
  const metrics = await db.query('SELECT * FROM metrics LIMIT 10')
  return <Dashboard data={metrics} />
}
tsx 复制代码
// components/InteractiveChart.tsx --- 客户端组件
'use client'

import { useState } from 'react'

export function InteractiveChart({ initialData }) {
  const [filter, setFilter] = useState('all')
  return (
    <div>
      <select onChange={e => setFilter(e.target.value)}>
        <option value="all">全部</option>
        <option value="week">近一周</option>
      </select>
      <Chart data={filter === 'all' ? initialData : initialData.filter(...)} />
    </div>
  )
}

服务端组件的 props 必须是可序列化的(JSON-compatible)。这意味着 props 可以是原始类型、数组、普通对象,但不能是函数、类实例或 Date 对象(需要转成字符串或时间戳传递)。React 对 props 序列化的要求与跨进程边界的数据传输一致。

组件树的绘制顺序是:服务端组件在服务端完成渲染后生成 React Element 树,这棵树被序列化成特殊的 RSC 格式(包含 Slot/Fallback 等标记),发送给客户端。客户端收到后用相同组件定义"水合"这棵树------不是重新执行组件逻辑,而是直接绑定事件处理器和 state。这意味着首次渲染的计算量全部发生在服务端,客户端只负责交互响应。

关于 bundle 体积的实测参考:Next.js 官方在 App Router 示例中测量过,一个包含数据库读取的服务端组件页面,客户端 JS 体积可以比 Pages Router 减少约 40%(具体数字因场景而异,不作为所有场景的承诺)。这是 RSC 设计的核心价值:把数据获取和渲染计算从客户端移到服务端,减少下载和解析成本。


03 布局系统:嵌套布局与持久化状态

App Router 的布局系统支持任意深度的嵌套,每个布局自动接收 children prop,这个 children 就是子路由的 page 内容。

tsx 复制代码
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }) {
  // 这个布局对 /dashboard 下所有子路由生效
  return (
    <div className="dashboard-shell">
      <Sidebar />
      <main>{children}</main>
    </div>
  )
}

与 Pages Router 的 _app.tsx 相比,嵌套布局的优势在于:状态不会在路由切换时丢失 。Next.js 在切换 page.tsx 时不会重新创建父级 layout.tsx,因此 layout 中的 state(如 Sidebar 的折叠状态)可以跨页面保持。

与布局相关的还有两个特殊文件:

template.tsxlayout.tsx 签名相同,但会在每次路由切换时重新创建------适合需要每次挂载时重置状态的动画场景。loading.tsx 放在任意目录层级,该目录下的页面切换会触发 loading UI(基于 React Suspense 实现),用户不会看到白屏。

tsx 复制代码
// app/dashboard/loading.tsx
export default function Loading() {
  return <DashboardSkeleton />
}

根布局 app/layout.tsx 是唯一必须存在的布局文件,它定义了 HTML 文档的 <html><body> 结构,通常长这样:

tsx 复制代码
import './globals.css'

export const metadata = {
  title: 'My App',
  description: 'Generated by Next.js',
}

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN">
      <body>{children}</body>
    </html>
  )
}

这里 metadata 导出会自动生成 <head> 中的 <title><meta description>、Open Graph 标签等。相比 Pages Router 的 <Head> 组件,metadata API 是声明式的、更类型安全的,且支持动态 metadata(通过 generateMetadata 函数)。

not-found.tsxerror.tsx 也是布局系统的组成部分。前者在 notFound() 被调用时渲染,后者捕获该层级以下的 JavaScript 错误。两者都可以嵌套------子目录的 error.tsx 只捕获该子路由树的错误,不会影响父级或其他平级路由。


04 数据获取:新的 fetch 语义与缓存策略

App Router 重构了数据获取模型。在 Pages Router 中,数据获取依赖 getServerSidePropsgetStaticProps,这两个函数与页面组件强耦合。App Router 引入的方案是在服务端组件内直接使用 fetch(),并通过 Next.js 扩展的 cache 和 revalidate 选项控制缓存行为。

tsx 复制代码
// 最基础:默认 cache(请求级缓存,视平台而定)
const res = await fetch('https://api.example.com/data')

// 不缓存:每次请求重新获取
const res = await fetch('https://api.example.com/data', {
  cache: 'no-store'
})

// 定时重新验证:静态生成 + ISR
const res = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 } // 每小时重新验证一次
})

cache 选项的取值有三个:cache: 'force-cache'(默认,优先缓存)、cache: 'no-store'(绕过缓存)、cache: 'only-no-store'(只允许不缓存的请求)。next.revalidate 是 Next.js 对 fetch 的扩展,设置为秒数后,Next.js 会在该时间窗口内返回缓存响应,过期后在下一次请求时触发后台重新验证------这等同于 ISR(Incremental Static Regeneration)行为。

对于数据库查询(不经过 fetch()),Next.js 提供了 unstable_cacheunstable_revalidate 工具函数来对 ORM 查询应用相同的缓存语义。使用 unstable_cache 包装查询函数后,相同的参数组合会自动命中缓存;unstable_revalidate 则对非 fetch 类数据源启用按时间重新验证。

并行数据获取 也变得更自然:不需要 Promise.all 的技巧,直接在服务端组件中用 async/await 顺序写,看起来是同步代码,执行时 Next.js 会自动并行发起请求。

tsx 复制代码
export default async function ProductPage({ params }) {
  const product = await getProduct(params.id)      // 并行执行
  const reviews = await getProductReviews(params.id) // 与上一行并行
  return (
    <div>
      <ProductDetail product={product} />
      <ReviewList reviews={reviews} />
    </div>
  )
}

上面两行 await 顺序写,但 Next.js 在服务端渲染时会识别出这两个 async 调用没有依赖关系,在底层并发执行,实际耗时等于最慢的一个而非两者之和。

关于数据预获取(prefetching):当用户鼠标悬停在 Link 组件上时,Next.js 会自动在后台预获取该链接对应的路由数据。prefetch prop 可以显式控制这一行为------设为 false 可以禁用特定链接的预获取,设为 URL 则可以预获取非 Link 目标的路由。


05 服务端动作:表单与 Mutations 的新方式

服务端动作(Server Actions)是 App Router 引入的另一个核心能力,允许在客户端组件中调用服务端函数,Next.js 自动生成安全的 API 端点并处理序列化。

tsx 复制代码
// app/actions.ts
'use server'

export async function updateProfile(formData: FormData) {
  const name = formData.get('name')
  await db.update(users).set({ name }).where(eq(users.id, session.user.id))
  revalidatePath('/profile')
}
tsx 复制代码
// components/ProfileForm.tsx
'use client'

import { updateProfile } from '@/app/actions'

export function ProfileForm() {
  return (
    <form action={updateProfile}>
      <input name="name" type="text" />
      <button type="submit">更新</button>
    </form>
  )
}

'use server' 指令可以放在文件顶部(文件内所有导出都是服务端动作),也可以单独放在某个函数上方(仅该函数为服务端动作)。两种方式的区别在于:文件级声明适合组织多个相关动作,函数级声明则允许在已有文件中渐进式引入服务端逻辑。

revalidatePath 是关键:调用后 Next.js 会清除指定路由的缓存,下次访问时触发重新渲染。这替代了 Pages Router 时代的 res.revalidate() 写法------现在服务端 mutation 后可以精确控制哪条路径失效,而不需要在 API Route 里手动调用 revalidate API。

revalidateTag 是另一个选择:当使用 unstable_cache 包装查询时,可以给缓存打上标签,然后通过 revalidateTag 精确清除特定缓存组合的缓存数据。相比 revalidatePath,标签化的方式更适合复杂数据依赖关系------同一条数据被多个页面共享时,只需要一次 revalidateTag 就能让所有相关页面下次访问时刷新。

安全方面,服务端动作默认做了两件事:请求来源校验 (只接受来自同一应用的表单提交)和输入序列化校验(防止注入)。但敏感操作(权限校验、越权检查)仍需在动作函数内显式实现。例如,删除其他用户数据的请求,仅靠来源校验是不够的,必须在动作内部验证当前 session 是否有权操作目标记录。

useActionState(原 useFormState,React 19 稳定化)是配合服务端动作使用的状态管理 Hook。它接收一个服务端动作和一个初始状态,返回一个包含当前状态和 action 触发函数的数组。状态中通常包含 pending(提交中)、error(错误信息)、success(成功状态)等字段,表单 UI 可以直接读取这些状态来展示加载指示器或错误提示。


06 流式渲染与 Suspense 边界

App Router 与 React 18 的 Suspense 深度集成,提供了开箱即用的流式渲染能力。当页面中某部分数据获取较慢时,不需要让整个页面等待------可以先返回已就绪的部分,再逐步补全慢的部分。

tsx 复制代码
import { Suspense } from 'react'

export default async function FeedPage() {
  return (
    <div>
      {/* 立即渲染,不需要等待推荐数据 */}
      <TrendingArticles />
      
      {/* 慢的评论流,逐步加载 */}
      <Suspense fallback={<CommentSkeleton />}>
        <CommentSection />
      </Suspense>
    </div>
  )
}

CommentSkeleton 会立即出现在 HTML 中,Next.js 在服务端 flush 这个部分后,客户端开始流式接收后续内容。实际体感是首屏更快,白屏时间大幅缩短。

这一能力对内容类应用(博客、商品详情页)尤为有价值:骨架屏比 Loading spinner 的体验好得多,因为用户从第一帧开始就知道内容在加载中。

流式渲染的实现原理:服务端在 flush HTML 时遇到 <Suspense> 边界,会先输出 fallback UI 对应的 HTML,然后在后台继续获取实际内容。内容就绪后,React 通过特殊的 RSC payload 将更新推送给客户端,客户端将 Suspense fallback 替换为真实内容。整个过程不需要重新加载页面,用户感知到的是"骨架屏逐渐变成完整页面"。

Streaming SSR 与传统 SSR 的关键区别:传统 SSR 必须等待整个页面数据全部就绪才能开始发送 HTML,Streaming SSR 则可以分批次 flush------已经准备好的部分立即发送,需要等待的部分先用 fallback 占位。这是 Next.js 在 App Router 模式下默认开启的能力,不需要额外配置。

对于有复杂数据依赖关系的页面,可以嵌套多层 Suspense------父 Suspense 快速返回外层骨架,子 Suspense 分别加载各自的数据。这样用户从第一帧就能看到页面结构,而不是等到所有数据都准备好才看到任何内容。


07 从 Pages Router 迁移:决策树与常见坑

迁移的判断标准

不是所有项目都适合立即迁移。以下条件满足其一,可以考虑迁移:

  • 项目数据获取逻辑重度依赖 getServerSideProps(动态数据展示)
  • 需要利用服务端组件减少客户端 bundle(首屏性能优化)
  • 已经在 Next.js 13+ 但仍使用 Pages Router,技术债逐渐积累

如果项目结构简单(静态页面为主),Pages Router 完全够用,不必为了迁移而迁移。框架迁移本身有成本,如果项目生命周期接近尾声,把工程资源投入在迁移上并不划算。

迁移的典型路径

第一步:目录共存 。在项目根目录新建 app/ 目录,保留原有的 pages/ 目录,两者可以共存。Next.js 根据路由匹配规则先查 app/ 再查 pages/。这样可以逐条路由迁移,不影响现有功能。

第二步:改造 getServerSideProps 。Pages Router 的 getServerSideProps 逻辑迁移到服务端组件:直接删除函数,把数据获取代码内联到组件中作为 async 函数体。客户端依赖的 props 通过父组件传递。

第三步:处理客户端边界 。检查原来 pages/ 中的 useEffectuseState 使用点,将这些子组件提取为客户端组件(加 'use client'),嵌入到新的服务端页面中。

第四步:迁移 API Routes 。将 pages/api/*.ts 重写到 app/api/*/route.ts,签名从 (req, res) 改为 Request / Response Web 标准 API。

第五步:验证与发布 。确认新路由功能与旧路由一致后,逐步将流量从 pages/ 迁移到 app/,最终删除 pages/ 目录。

迁移中的 5 类高频错误

错误 1:在服务端组件中使用 hooks 。症状是运行时报"Hooks cannot be called in Server Components"。解法:将有 hook 调用的组件抽离为独立的客户端组件文件,在文件顶部加 'use client'

错误 2:混用 Providers 。Pages Router 时代的 _app.tsx 通常放 Context Provider。迁移后需要在 app/layout.tsx 中使用同样的 Provider 结构,但要注意 Context 只在客户端有效------Provider 本身需要是客户端组件('use client'),它包裹的子组件可以是服务端组件。

错误 3:window / document 访问错误 。服务端组件在构建时不存在 window 对象。解法:将这类代码限制在客户端组件内,或使用 typeof window !== 'undefined' 的条件守卫,并在条件不满足时返回 null 或占位 UI。

错误 4:缓存策略不当导致数据过期 。迁移后忘了 cache: 'no-store'revalidate,导致页面显示旧数据。解法:在每个 fetch 调用处明确注释缓存意图,建议在代码 review 时重点检查数据获取处的缓存配置。

错误 5:API Route Handler 写法变化 。Pages Router 的 pages/api/*.ts 在 App Router 中改成了 app/api/*/route.ts,且签名从 (req, res) 变成了 Request / Response 的 Web 标准 API 风格。原来代码里大量的 req.bodyres.status(200).json() 需要改写成 request.json()Response 构造函数。


08 Next.js 16 的新增能力与现状

截至目前,Next.js 16 的主要更新集中在以下几个方面:

Partial Prerendering(PPR) 实验性引入,结合静态生成与服务端渲染------页面骨架立即返回(静态),动态区域异步流式补全。适合内容为主但有个性化数据的主页场景。PPR 的实现方式是:页面中静态部分直接渲染为 HTML,动态部分用 Suspense 边界包裹,服务端流式推送动态内容时带上 RSC payload,客户端接收到后自动合并。

TurboPack 稳定化:Turbopack 替代 Webpack 成为默认 bundler(开发模式),冷启动速度提升明显,热更新延迟更低。生产构建仍使用 SWC 编译器。Turbopack 的增量编译基于 Rust,比 Webpack 的 JavaScript 实现快了 10 倍以上(具体倍数视项目规模而异,不作为所有项目的承诺)。

Server Actions 增强 :改进的错误处理和重试机制,以及 useActionState(原 useFormState) Hook 的稳定化,让表单状态管理更符合 React 规范。服务端动作现在支持 useOptimistic------可以在提交时立即更新 UI(乐观更新),收到服务端响应后再调整到真实状态,省去等待网络的交互延迟。

Metadata API 完善 :静态和动态 metadata 的类型安全更好,generateMetadata 函数对动态路由的参数推断更准确,减少了运行时 404 问题。新的 viewport 导出可以独立设置移动端视口配置,与 metadata 导出分离后更清晰。

缓存精细化控制 :除了 revalidate 时间窗口,还可以对特定路由段(route segment)设置实验性的缓存策略,通过 export const dynamic = 'force-dynamic'export const dynamic = 'force-static' 来覆盖 fetch 级别的缓存设置。


09 反向清单:10 条常见错误

  1. 在服务端组件文件顶部直接 import { useState } from 'react'(应抽离客户端组件)
  2. 全局 _app.tsx 里的 Provider 没有转换为 'use client'(Context 只在客户端有效)
  3. getStaticProps + revalidate 迁移时漏掉 revalidate 配置(导致数据不更新)
  4. pages/api/ 迁移到 app/api/ 时仍用 (req, res) 签名(应改为 Request / Response
  5. 服务端组件访问 window 对象(构建时 crash,应限制在客户端组件内)
  6. 服务端动作没有在函数顶部加 'use server'(Next.js 不会自动识别该函数为服务端端点)
  7. revalidatePath 漏写(mutation 后页面不刷新,用户看不到最新数据)
  8. 嵌套布局的 children prop 没有传递给实际子节点(页面内容消失,只剩空白布局)
  9. loading.tsx 放在根目录而非子目录(导致全局 loading 而非按路由加载,用户换页时看到整页骨架而非当前区域骨架)
  10. 迁移时直接删除 pages/ 而非先让 app/pages/ 共存(新路由未就绪时线上 404,影响真实用户)

10 总结与适用场景

App Router 代表了 Next.js 对"服务端优先"前端架构的系统性表达。Server Components 减少客户端 bundle、布局系统让路由与 UI 结构对齐、服务端动作简化了表单与 mutations 的写法、流式渲染改善了首屏体验------这些能力组合在一起,让 Next.js 从一个"React 框架"进化为了一个"全栈 React 框架"。

如果项目目标是构建内容型站点(博客、文档站、电商产品页),App Router 的静态生成 + ISR 方案性价比极高。如果项目重度依赖实时交互(看板、在线协作工具),Pages Router 的成熟生态可能更稳妥。

迁移不是目的,可维护的性能更好的产品才是。

相关推荐
OpenTiny社区1 小时前
TinyEngine 2.11版本发布:AI 进一步融入画布,Vue 工程可一键转DSL
前端·vue.js·github
小雪_Snow1 小时前
JavaScript 中的三种常用事件
开发语言·前端·javascript
Hyyy1 小时前
Electron-Vite是什么,和Vite有什么区别
前端·面试
蓝胖子酱1 小时前
在 Electron 中用 electron-store 实现加密存储与自定义格式文件
前端·javascript·数据库
虚焦像素_2 小时前
海康 Web 插件版接入指南:用法与注意事项
前端
虚焦像素_2 小时前
海康 Web 无插件版接入指南:用法与注意事项
前端
aaron3 小时前
前端演进史:当你没留意时,前端发生了什么
前端
不好听6133 小时前
前端路由完全指南(上篇):从原理到 6 种路由模式
前端·react.js
像我这样帅的人丶你还3 小时前
🚀大文件上传的那些事
前端·javascript·架构