React 基础体系 · 第 22/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Next.js 应用架构:路由、渲染、数据、缓存、Server Actions 和部署

Next.js 应用架构的难点不在于记住某个目录名或 API,而在于理解几个边界如何共同决定一次请求的结果:

  1. 路由系统把 URL 映射为组件树和服务器端处理逻辑。
  2. React Server Components(RSC)决定哪些组件在服务器执行,哪些组件发送到浏览器。
  3. 渲染模式决定 HTML、RSC Payload 和数据何时生成。
  4. 数据访问与多层缓存决定结果是否复用、何时失效。
  5. Server Actions 决定浏览器如何安全地调用服务器端变更逻辑。
  6. 部署目标决定 Node.js、Edge、静态导出、文件系统和运行时能力是否成立。

下面以 App Router 为主,示例基于 React 19、现代 TypeScript 和当前主流 Next.js 能力。Next.js 的缓存默认值和部分 API 在不同大版本中发生过变化,因此示例尽量使用显式配置,而不是依赖容易改变的默认行为。


一、先建立请求模型:一个 URL 不只对应一个页面

在 App Router 中,一个页面通常不是单个组件,而是一棵由布局、页面、加载状态和错误边界组成的组件树:

app/
├── layout.tsx              根布局
├── page.tsx                /
├── loading.tsx             根级加载 UI
├── error.tsx               根级客户端错误边界
├── not-found.tsx           404 UI
├── products/
│   ├── layout.tsx          /products 下共享布局
│   ├── page.tsx            /products
│   └── [id]/
│       ├── page.tsx        /products/:id
│       └── loading.tsx
└── api/
    └── health/
        └── route.ts        /api/health

路由解析可以近似表示为:

URLsegment matchingroute treerenderHTML + RSC Payload\text{URL} \xrightarrow{\text{segment matching}} \text{route tree} \xrightarrow{\text{render}} \text{HTML + RSC Payload}

其中:

  • segment 是路径段,例如 products[id]
  • route tree 是由 layout.tsxpage.tsx 等文件构成的组件树;
  • HTML 用于首屏展示;
  • RSC Payload 是服务器组件结果的序列化表示,供 React 在客户端恢复组件树并进行后续导航。

1. 静态路由、动态路由与捕获段

静态路径:

app/about/page.tsx       => /about

动态路径:

app/products/[id]/page.tsx => /products/abc

页面接收的参数通常是 Promise 形式:

// app/products/[id]/page.tsx
type PageProps = {
  params: Promise<{ id: string }>
}

export default async function ProductPage({ params }: PageProps) {
  const { id } = await params

  return <h1>Product: {id}</h1>
}

在一些旧版本或旧类型生成方式中,params 仍可能表现为普通对象。实际项目应以当前 Next.js 版本生成的类型和官方文档为准,不要把不同版本的签名混用。

多段动态路径可以使用捕获段:

app/docs/[...slug]/page.tsx

它可以匹配:

/docs/react
/docs/react/server-components

对应的参数是:

{ slug: ['react', 'server-components'] }

可选捕获段:

app/docs/[[...slug]]/page.tsx

还可以匹配 /docs 本身,此时 slug 可能不存在。

2. layoutpagetemplate 的生命周期差异

layout.tsx 在同一段路由下共享,并且在客户端导航时通常保持状态。例如:

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <section>
      <aside>导航栏</aside>
      <main>{children}</main>
    </section>
  )
}

/dashboard/a 导航到 /dashboard/b 时,dashboard/layout.tsx 不一定重新挂载,因此布局中的客户端状态可以保留。

template.tsx 与布局形状相似,但每次导航都会创建新的实例。需要“共享结构但不共享挂载状态”时才使用它。

page.tsx 是可被路由直接访问的叶子节点。只有 page.tsxroute.ts 才通常形成可访问的路由入口;任意普通文件不会自动暴露成 URL。

3. loading.tsxerror.tsxnot-found.tsx

loading.tsx 会被 Next.js 转换为该路由段的 Suspense 边界,用于在服务器仍在获取数据时先发送加载界面:

// app/products/loading.tsx
export default function Loading() {
  return <p>正在加载商品列表……</p>
}

error.tsx 必须是客户端组件,因为它需要使用错误边界生命周期:

// app/products/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  return (
    <div>
      <p>商品加载失败。</p>
      <button onClick={() => reset()}>重试</button>
    </div>
  )
}

这里的 error 不能直接当作安全的用户展示内容。生产环境应记录服务端日志,用 digest 或请求追踪 ID 关联诊断信息,而不是把堆栈和数据库错误直接发送给用户。

not-found.tsx 对应“资源不存在”,它和异常不同:

import { notFound } from 'next/navigation'

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id)

  if (!product) {
    notFound()
  }

  return <h1>{product.name}</h1>
}

notFound() 会中断当前渲染路径,转到最近的 not-found.tsx,而不是进入 error.tsx

4. 页面路由与 Route Handler 不是一回事

页面路由返回 UI:

// app/page.tsx
export default function HomePage() {
  return <h1>首页</h1>
}

Route Handler 返回 HTTP 响应:

// app/api/health/route.ts
export async function GET() {
  return Response.json({
    ok: true,
    time: new Date().toISOString(),
  })
}

访问 /api/health 的结果是 JSON,而不是 React 页面。它适合实现 Webhook、内部 API、文件下载或与非 React 客户端通信的 HTTP 接口。

Route Handler 也必须明确处理输入:

// app/api/search/route.ts
import { z } from 'zod'

const querySchema = z.object({
  q: z.string().trim().min(1).max(100),
})

export async function GET(request: Request) {
  const url = new URL(request.url)
  const result = querySchema.safeParse({
    q: url.searchParams.get('q') ?? '',
  })

  if (!result.success) {
    return Response.json(
      { error: 'invalid query' },
      { status: 400 },
    )
  }

  return Response.json({ query: result.data.q })
}

类型检查只能保证 TypeScript 代码内部的类型关系,不能验证浏览器、爬虫或第三方服务发送的真实 HTTP 输入。边界处仍需要运行时校验。


二、RSC:服务端边界决定代码和数据往哪里流动

React Server Components 的核心不是“服务器渲染 JSX”,而是:某些组件只在服务器执行,结果以 RSC Payload 形式传递给客户端;这些组件本身不会作为可交互 JavaScript 发送到浏览器。

在 App Router 中,默认组件是 Server Component:

// app/products/page.tsx
export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <ul>
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  )
}

它可以直接访问服务器资源,例如数据库客户端、文件系统或服务端环境变量,但这些代码不会进入浏览器 bundle。

1. 'use client' 是边界声明,不是“让函数在客户端执行”

需要状态、事件处理器、浏览器 API 或客户端生命周期时,声明客户端组件:

// app/products/AddToCartButton.tsx
'use client'

import { useState } from 'react'

export function AddToCartButton() {
  const [pending, setPending] = useState(false)

  return (
    <button
      disabled={pending}
      onClick={() => {
        setPending(true)
        // 调用 Server Action 或客户端 API
      }}
    >
      {pending ? '加入中……' : '加入购物车'}
    </button>
  )
}

边界规则可以概括为:

Server Component
├── 可以渲染 Server Component
└── 可以渲染 Client Component

Client Component
└── 不能直接导入 Server Component

但客户端组件可以通过 children 接收服务器组件已经生成的内容:

// app/layout.tsx
import ClientShell from './ClientShell'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="zh-CN">
      <body>
        <ClientShell>{children}</ClientShell>
      </body>
    </html>
  )
}

这里 ClientShell 是客户端组件,但 children 可以是由服务器组件生成的 React 节点。客户端组件不需要重新导入页面服务器代码。

2. 序列化边界限制了 props

跨越 RSC 边界的 props 必须能够被 React 的协议表示。安全的例子包括:

<ClientCard
  title="商品"
  count={3}
  tags={['new', 'sale']}
  product={{ id: 'p1', name: '键盘' }}
/>

不应直接传递:

<ClientCard
  onClick={() => {}}
  databaseConnection={db}
  socket={socket}
/>

函数、数据库连接、Socket 等不是普通可序列化数据。事件处理器必须定义在客户端组件内部,数据库连接必须留在服务器端。

“可序列化”的具体支持范围受 React 和 Next.js 版本影响;工程上应优先传递字符串、数字、布尔值、数组、普通对象,以及框架明确支持的有限内建类型。不要把“TypeScript 能通过”误认为“RSC 协议一定能传输”。

3. 数据应尽量停留在服务器组件

下面的结构把数据库查询留在服务器端,只把最小结果传给交互组件:

// app/products/page.tsx
import Filter from './Filter'

export default async function ProductsPage() {
  const products = await db.product.findMany({
    select: {
      id: true,
      name: true,
      price: true,
    },
  })

  return (
    <>
      <Filter />
      <ProductList products={products} />
    </>
  )
}
// app/products/Filter.tsx
'use client'

import { useState } from 'react'

export default function Filter() {
  const [keyword, setKeyword] = useState('')

  return (
    <input
      value={keyword}
      onChange={(event) => setKeyword(event.target.value)}
      placeholder="搜索商品"
    />
  )
}

如果把整个页面都标记为 'use client',通常会产生三个后果:

  1. 数据查询不能直接写在该组件中;
  2. 更多 JavaScript 被发送到浏览器;
  3. 原本可以在服务器保护的模块、密钥和查询逻辑被迫改为 API 调用。

客户端组件并非错误;错误的是没有根据交互需求划分边界。


三、渲染:静态、动态、流式和客户端导航分别发生什么

“SSR”通常被泛指为服务器生成 HTML,但 Next.js 实际上需要区分至少四件事:

  • HTML 在何时生成;
  • RSC Payload 在何时生成;
  • 数据查询是否复用;
  • 浏览器导航是否重新请求服务器。

1. 静态渲染

静态渲染指页面结果可以在请求到来前生成并复用。它适合公开、变化不频繁、可被预生成的内容。

例如:

// app/about/page.tsx
export default function AboutPage() {
  return <h1>关于我们</h1>
}

在构建或预渲染阶段生成静态结果后,CDN 可以直接返回文件或缓存响应,不必每次调用 Node.js。

动态路由也可以预生成:

// app/products/[id]/page.tsx
export async function generateStaticParams() {
  const products = await getPopularProducts()

  return products.map((product) => ({
    id: product.id,
  }))
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const product = await getProduct(id)

  if (!product) {
    notFound()
  }

  return <h1>{product.name}</h1>
}

这只意味着返回的这些参数可以在构建或预渲染时处理,不代表任意新 id 都自动变成静态页面。未列出的参数如何处理,取决于具体版本和路由配置。

2. 动态渲染

动态渲染指请求到来时才生成结果,常见原因包括:

  • 读取请求头、Cookie 或用户身份;
  • 查询必须实时变化的数据;
  • 使用明确的动态数据策略;
  • 页面依赖当前时间、随机数或请求上下文。

例如:

import { cookies } from 'next/headers'

export default async function AccountPage() {
  const cookieStore = await cookies()
  const sessionId = cookieStore.get('session')?.value

  if (!sessionId) {
    return <p>请先登录</p>
  }

  const account = await getAccount(sessionId)
  return <h1>{account.name}</h1>
}

这里页面结果与请求 Cookie 绑定,不能把同一个 HTML 随意提供给所有用户。

一个常见错误是把用户特定数据放进可共享缓存:

// 错误示意:把用户身份作为隐式上下文,却缓存了结果
const profile = await unstable_cache(
  () => getCurrentUserProfile(),
  ['current-user-profile'],
)()

缓存键没有包含用户身份,结果可能被不同用户复用。对于用户隔离数据,应显式包含稳定的用户标识,或者完全不使用共享缓存。

3. 流式渲染

流式渲染不是“服务器先返回半截 HTML 字符串”,而是服务器先发送已经完成的部分,后续 Suspense 边界完成后继续发送内容。

// app/dashboard/page.tsx
import { Suspense } from 'react'

async function Revenue() {
  const revenue = await getRevenue()
  return <p>收入:{revenue}</p>
}

async function Orders() {
  const orders = await getOrders()
  return <p>订单数:{orders.length}</p>
}

export default function DashboardPage() {
  return (
    <>
      <h1>仪表盘</h1>

      <Suspense fallback={<p>收入加载中……</p>}>
        <Revenue />
      </Suspense>

      <Suspense fallback={<p>订单加载中……</p>}>
        <Orders />
      </Suspense>
    </>
  )
}

请求路径为:

浏览器请求
  │
  ├── 返回已完成的页面骨架
  ├── 返回 Revenue 的 fallback
  ├── Revenue 完成,替换对应边界
  └── Orders 完成,替换对应边界

流式渲染改善的是首字节和可感知响应,不会让慢查询本身变快。若所有内容都位于同一个未拆分的 Promise 之后,流式能力就没有足够的边界可发送。

4. 客户端导航与浏览器缓存

使用 next/link 导航时,Next.js 通常不会重新下载整个 HTML 文档,而是请求目标路由所需的 RSC Payload,并复用已有布局。这解释了为什么:

  • 页面导航看起来比完整刷新快;
  • 布局状态可能保留;
  • 客户端 Router Cache 会影响“返回页面”时看到的内容。

完整刷新和客户端导航不是同一条请求路径。诊断缓存问题时必须明确是:

首次文档请求
还是
Link 导航
还是
浏览器前进/后退

四、数据访问:先区分来源、生命周期和一致性要求

一个数据请求至少有三个独立问题:

  1. 数据从哪里来:数据库、HTTP API、文件、环境变量;
  2. 结果活多久:每次请求、几秒、直到主动失效;
  3. 谁能看到:所有用户、某个租户、某个用户。

可以使用如下抽象:

type CachePolicy =
  | { kind: 'request' }
  | { kind: 'time'; seconds: number }
  | { kind: 'manual'; tag: string }
  | { kind: 'private'; userId: string }

不要只问“这个页面是不是 SSR”,因为 SSR 只描述生成位置,不完整描述数据是否缓存。

1. 直接访问数据库

服务器组件可以直接调用数据库:

// lib/products.ts
import 'server-only'
import { db } from './db'

export async function getProducts() {
  return db.product.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
    select: {
      id: true,
      name: true,
      price: true,
    },
  })
}

import 'server-only' 的作用是:当某个客户端组件错误地导入该模块时,让构建阶段尽早失败。它不是运行时权限系统,也不能替代数据库权限控制。

2. 服务端调用外部 HTTP API

export async function getWeather(city: string) {
  const response = await fetch(
    `https://api.example.com/weather?city=${encodeURIComponent(city)}`,
    {
      next: {
        revalidate: 300,
        tags: [`weather:${city}`],
      },
    },
  )

  if (!response.ok) {
    throw new Error(`Weather API failed: ${response.status}`)
  }

  return response.json() as Promise<{
    city: string
    temperature: number
  }>
}

这里:

  • revalidate: 300 表示结果可以在约 300 秒后重新验证;
  • tags 提供主动失效的索引;
  • HTTP 状态码异常必须显式处理,因为 fetch 对 404 或 500 通常不会自动抛出异常。

数据 API 的错误处理不能只依靠组件的 error.tsx。组件边界负责展示降级界面,数据层还应区分:

  • 认证失败:可能需要登录;
  • 参数错误:返回 400;
  • 资源不存在:调用 notFound()
  • 上游暂时失败:可重试或返回缓存;
  • 程序缺陷:记录错误并进入异常边界。

五、Next.js 的缓存不是一个缓存,而是四层缓存

理解缓存时,必须区分以下四层。它们的生命周期、存储位置和失效方法不同。

请求内复用
    ↓
数据缓存(服务器端)
    ↓
完整路由缓存(HTML + RSC Payload)
    ↓
客户端 Router Cache(浏览器内存)

1. Request Memoization:一次渲染内的重复请求

在同一次服务器渲染过程中,框架可能对相同的 fetch 请求去重:

const a = fetch('https://api.example.com/products')
const b = fetch('https://api.example.com/products')

const [resA, resB] = await Promise.all([a, b])

常见实现会避免同一渲染过程中的重复网络请求,但这不是跨请求的持久化缓存。一次请求结束后,不能据此推断下一次请求仍会复用结果。

如果不是 fetch,而是数据库函数或 SDK 查询,不能假设它自动享有同样的去重行为。需要时应显式使用 React 的请求级缓存工具或业务层去重机制,并确认对应版本的支持范围。

2. Data Cache:数据结果的服务器端缓存

next.revalidate 的请求可以缓存数据结果:

const response = await fetch('https://api.example.com/posts', {
  next: {
    revalidate: 60,
  },
})

缓存时间线可表示为:

t=0       首次请求,缓存 miss,获取数据 D0
t=30      返回 D0
t=60      进入重新验证窗口
t=60+     具体行为取决于框架版本和请求策略:
          可能先返回旧值并后台刷新,也可能等待重新验证

不要把“60 秒”理解为精确的强一致 TTL。分布式部署、边缘缓存和重新验证时机可能使实际行为存在偏差。若业务需要严格一致,应在写入后主动失效或绕过缓存。

显式不缓存:

const response = await fetch('https://api.example.com/account', {
  cache: 'no-store',
})

对于用户私有数据,no-store 是容易理解的选择,但会牺牲缓存带来的性能和成本收益。

3. Full Route Cache:完整路由输出缓存

当一个路由能够静态生成时,Next.js 可以缓存该路由的 HTML 和 RSC Payload。它缓存的是“页面输出”,不是数据库本身。

因此有两种不同情况:

数据缓存命中,但路由仍动态渲染
路由输出缓存命中,页面无需重新执行组件

一个页面包含多个数据源时,最慢或最动态的数据可能决定整个页面是否需要请求时生成。不能看到某个 fetch 被缓存,就推断整个页面是静态页面。

4. Router Cache:浏览器中的客户端路由缓存

客户端导航后,浏览器内存中可能保留已访问路由的 RSC 片段。它的作用是提高导航和返回操作速度,但也会造成一个常见误解:

服务端数据已经失效,不代表当前浏览器画面立刻变化。

服务器缓存、CDN 缓存和客户端 Router Cache 可能同时存在。失效时要回答三个问题:

  1. 服务端数据缓存是否失效;
  2. 路由输出是否重新生成;
  3. 当前浏览器是否重新请求或刷新了对应 RSC 片段。

六、缓存失效:时间、路径和标签分别解决什么问题

1. 基于时间的重新验证

适合新闻列表、公共配置、商品目录等允许短暂陈旧的数据:

await fetch('https://api.example.com/catalog', {
  next: { revalidate: 300 },
})

它的因果关系是:

数据变化
  └── 不会立即通知 Next.js
      └── 到达重新验证条件后,下一次访问触发更新流程

如果价格、库存、权限等数据要求写入后立即可见,仅设置时间并不能满足要求。

2. 基于路径的失效

写操作成功后,可以让相关路径重新生成:

import { revalidatePath } from 'next/cache'

await updateProduct(productId, input)
revalidatePath('/products')
revalidatePath(`/products/${productId}`)

路径失效影响哪些缓存层、何时反映到当前客户端,与 Next.js 版本和调用上下文有关。Server Action 内调用时,框架可以配合当前导航刷新;Route Handler 内调用则通常需要客户端重新请求或刷新页面。

3. 基于标签的失效

标签把“数据对象”与“页面路径”解耦:

await fetch(`https://api.example.com/products/${id}`, {
  next: {
    tags: [`product:${id}`],
  },
})

更新后主动失效:

import { revalidateTag } from 'next/cache'

revalidateTag(`product:${id}`)

不同 Next.js 版本对 revalidateTag 的参数签名和推荐用法存在变化;新版本文档更强调带 profile 的重新验证语义,以及在 Server Action 中使用立即失效能力的专用 API。升级时应检查当前版本文档,不能把旧版的单参数示例无条件迁移到新版。

标签的关键价值是:

页面 A、页面 B、API C
      │
      └── 都读取 product:123
                    │
                    └── 一次失效,所有关联读取都重新验证

标签不是权限边界。任何能调用失效逻辑的代码都必须先通过认证和授权。


七、Server Actions:把变更逻辑留在服务器

Server Action 是可以由客户端触发、但在服务器执行的异步函数。它适合表单提交、创建记录、更新状态和删除资源。

1. 一个完整的表单 Action

// app/products/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'

const createProductSchema = z.object({
  name: z.string().trim().min(1, '名称不能为空').max(100),
  price: z.coerce.number().finite().nonnegative(),
})

export type CreateProductState = {
  ok: boolean
  message: string
}

export async function createProduct(
  _previousState: CreateProductState,
  formData: FormData,
): Promise<CreateProductState> {
  const parsed = createProductSchema.safeParse({
    name: formData.get('name'),
    price: formData.get('price'),
  })

  if (!parsed.success) {
    return {
      ok: false,
      message: parsed.error.issues[0]?.message ?? '输入无效',
    }
  }

  // 必须在服务器端重新读取会话并授权,不能信任表单中的 userId
  const user = await getCurrentUser()
  if (!user || user.role !== 'editor') {
    return {
      ok: false,
      message: '没有操作权限',
    }
  }

  await db.product.create({
    data: {
      name: parsed.data.name,
      price: parsed.data.price,
    },
  })

  revalidatePath('/products')

  return {
    ok: true,
    message: '商品已创建',
  }
}

客户端表单:

// app/products/ProductForm.tsx
'use client'

import { useActionState } from 'react'
import {
  createProduct,
  type CreateProductState,
} from './actions'

const initialState: CreateProductState = {
  ok: false,
  message: '',
}

export function ProductForm() {
  const [state, formAction, pending] = useActionState(
    createProduct,
    initialState,
  )

  return (
    <form action={formAction}>
      <label>
        名称
        <input name="name" required />
      </label>

      <label>
        价格
        <input name="price" type="number" min="0" step="0.01" required />
      </label>

      <button disabled={pending}>
        {pending ? '保存中……' : '保存'}
      </button>

      {state.message && (
        <p role="status">{state.message}</p>
      )}
    </form>
  )
}

这里的生命周期是:

用户提交表单
  ↓
浏览器构造 FormData
  ↓
调用服务器端 Action
  ↓
服务器校验输入
  ↓
服务器读取会话并授权
  ↓
写入数据库
  ↓
使相关缓存失效
  ↓
返回 Action 状态
  ↓
客户端更新表单反馈

2. 'use server' 不是自动授权

Server Action 的入口可能被构造请求直接调用。因此以下做法不安全:

'use server'

export async function deleteUser(formData: FormData) {
  const userId = String(formData.get('userId'))
  await db.user.delete({ where: { id: userId } })
}

问题有三个:

  1. userId 来自客户端,攻击者可以修改;
  2. 没有检查当前操作者身份;
  3. 没有确认操作者是否有权删除目标用户。

正确逻辑必须在服务器端获得当前会话,并进行对象级授权:

const actor = await getCurrentUser()
if (!actor || actor.role !== 'admin') {
  throw new Error('Forbidden')
}

const target = await db.user.findUnique({ where: { id: targetId } })
if (!target) {
  notFound()
}

await db.user.delete({ where: { id: targetId } })

“服务器执行”只解决代码位置问题,不自动解决认证、授权、CSRF、输入校验、幂等性或审计问题。

3. 重复提交与幂等性

网络重试、用户双击、浏览器恢复和代理重放都可能导致 Action 执行多次。创建订单等操作不能只依赖按钮禁用。

可使用客户端生成的幂等键,并在数据库上建立唯一约束:

await db.order.create({
  data: {
    idempotencyKey,
    userId: actor.id,
    amount,
  },
})

数据库约束保证第二次提交失败或返回已有结果。仅在内存中记录幂等键,在多实例部署中通常不可靠。


八、并发与数据流:让独立查询真正并行

下面的写法会串行执行:

const user = await getUser()
const orders = await getOrders()
const notifications = await getNotifications()

如果三个查询互不依赖,理想的等待时间近似为:

Tserial=T1+T2+T3T_{\text{serial}} = T_1 + T_2 + T_3

并行写法为:

const [user, orders, notifications] = await Promise.all([
  getUser(),
  getOrders(),
  getNotifications(),
])

此时等待时间近似为:

Tparallel=max(T1,T2,T3)T_{\text{parallel}} = \max(T_1, T_2, T_3)

例如三个查询分别耗时 100ms、300ms、500ms:

串行:100 + 300 + 500 = 900ms
并行:max(100, 300, 500) = 500ms

但并行不是无条件更好:

  • 数据库连接池可能因此耗尽;
  • 上游 API 可能有并发限制;
  • 如果 getOrders() 需要 user.id,它就不能与 getUser() 同时启动;
  • 失败策略可能不同,不能把必须成功的数据和可选数据放进同一个 Promise.all

依赖关系示例:

const user = await getUser()

const [orders, recommendations] = await Promise.all([
  getOrders(user.id),
  getRecommendations(user.id),
])

可选数据可使用 Promise.allSettled

const results = await Promise.allSettled([
  getRecommendations(user.id),
  getUnreadCount(user.id),
])

const recommendations =
  results[0].status === 'fulfilled' ? results[0].value : []

const unreadCount =
  results[1].status === 'fulfilled' ? results[1].value : 0

这表示“推荐服务失败不影响页面主流程”,但不应掩盖错误。失败仍需日志、指标或追踪记录。


九、错误路径:把“没有数据”“输入错误”和“系统故障”分开

一个可维护的页面至少区分四种状态:

请求未授权     → 登录或 401/403
资源不存在     → notFound() 或 404
输入不合法     → 表单字段错误或 400
系统异常       → error.tsx、500、重试和日志

示例:

import { notFound, redirect } from 'next/navigation'

export default async function InvoicePage({
  params,
}: {
  params: Promise<{ id: string }>
}) {
  const { id } = await params
  const session = await getSession()

  if (!session) {
    redirect('/login')
  }

  const invoice = await getInvoice(id)

  if (!invoice) {
    notFound()
  }

  if (invoice.userId !== session.userId) {
    // 对外通常不暴露“该资源是否存在”,具体策略取决于业务
    notFound()
  }

  return <InvoiceView invoice={invoice} />
}

redirect() 是控制流中断函数,调用后后续代码不会继续执行。不要写成:

if (!session) {
  redirect('/login')
}
useSession(session) // 类型和控制流上都不应依赖这里继续执行

如果数据库连接失败,应让异常进入错误处理链,而不是伪装成“资源不存在”。否则监控无法区分故障和正常 404,用户也会得到错误提示。


十、搜索参数、Cookie 与动态性的关系

查询字符串经常被用于筛选:

// app/products/page.tsx
type PageProps = {
  searchParams: Promise<{
    q?: string
    page?: string
  }>
}

export default async function ProductsPage({
  searchParams,
}: PageProps) {
  const params = await searchParams
  const q = params.q?.trim() ?? ''
  const page = Math.max(Number(params.page ?? 1), 1)

  const products = await searchProducts({ q, page })

  return <ProductResults products={products} />
}

需要注意:

  • 搜索参数通常影响页面输出;
  • 不同查询字符串可能形成不同路由结果;
  • 将搜索参数拼入缓存键,否则可能把一个查询的结果错误地复用于另一个查询;
  • 不要直接把用户输入拼接进 SQL,应使用参数化查询或 ORM 的安全条件构造。

Cookie 和请求头则通常与当前请求相关:

import { headers } from 'next/headers'

export default async function LocalePage() {
  const requestHeaders = await headers()
  const locale = requestHeaders.get('accept-language') ?? 'zh-CN'

  return <p>当前语言:{locale}</p>
}

如果页面输出依赖 headers(),它不能被当成所有请求共用的静态 HTML。除非缓存键明确包含所有影响结果的请求因素,否则共享缓存会产生串数据风险。


十一、一个可运行的端到端目录示例

下面给出一个最小的商品列表应用结构:

app/
├── layout.tsx
├── products/
│   ├── page.tsx
│   ├── ProductForm.tsx
│   ├── actions.ts
│   ├── loading.tsx
│   └── error.tsx
lib/
├── db.ts
└── products.ts

服务器数据层:

// lib/products.ts
import 'server-only'
import { db } from './db'

export async function getProducts() {
  return db.product.findMany({
    where: { published: true },
    orderBy: { createdAt: 'desc' },
  })
}

服务器页面:

// app/products/page.tsx
import { getProducts } from '@/lib/products'
import { ProductForm } from './ProductForm'

export const dynamic = 'force-dynamic'

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <main>
      <h1>商品</h1>
      <ProductForm />

      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name}:{product.price}
          </li>
        ))}
      </ul>
    </main>
  )
}

dynamic = 'force-dynamic' 的含义是明确要求该路由按动态方式处理。它适合示例或必须实时读取数据库的页面,但不能因为“担心缓存”就全站使用,否则会失去静态输出和缓存收益。

运行开发服务器:

npm run dev

预期结果:

本地启动开发服务器
访问 http://localhost:3000/products
显示商品列表和创建表单
提交表单后写入数据库,并刷新 /products 的相关结果

生产构建:

npm run build
npm run start

如果构建失败,应先修复类型、导入边界、环境变量和静态预渲染错误,再进入部署。next build 成功并不等于所有运行时路径都安全;动态路由、未覆盖的异常分支和真实上游服务仍需验证。


十二、部署目标:Node、Edge、静态导出不是同一种环境

1. Node.js 部署

典型流程:

npm ci
npm run build
npm run start

npm run start 启动的是生产服务器。它需要:

  • Node.js 运行时;
  • 构建产物;
  • 正确的环境变量;
  • 数据库和外部服务网络连通;
  • 反向代理或平台将流量转发到应用端口。

Node.js 运行时通常适合数据库驱动、文件系统、长连接和完整 npm 生态,但具体能力仍取决于部署平台。

2. Edge Runtime

Edge Runtime 常用于靠近用户的边缘执行环境,但通常具有更严格的 API 和依赖限制:

  • 不能默认使用所有 Node.js 内建模块;
  • 某些数据库驱动依赖 TCP 或原生模块,无法直接运行;
  • 包体积、执行时长和连接模型可能不同;
  • 请求可能在多个区域执行,数据一致性和延迟模型需要重新评估。

不要因为某个接口“逻辑简单”就自动选择 Edge。应先确认所有依赖和数据库访问方式支持目标运行时。

3. 静态导出

静态导出适合完全不依赖服务器运行时的站点。它可以生成 HTML、CSS 和 JavaScript 文件交给静态 CDN。

但以下能力通常不能直接依赖静态导出:

  • Server Actions;
  • 动态请求时读取 Cookie 或 Header;
  • 服务端数据库查询;
  • 需要服务器执行的 Route Handler;
  • 运行时动态生成页面。

静态导出不是“把 SSR 变快”,而是取消服务器渲染能力,换取更简单的文件分发模型。需要实时数据时,浏览器只能调用外部 API,或者重新引入一个独立后端。


十三、环境变量与服务器密钥

环境变量需要按可见性区分:

DATABASE_URL=postgres://...
SESSION_SECRET=...
NEXT_PUBLIC_API_BASE=https://public.example.com

一般规则:

  • 不带 NEXT_PUBLIC_ 的变量仅应在服务器代码中使用;
  • NEXT_PUBLIC_ 的变量会被嵌入客户端 bundle,任何用户都可以看到;
  • 不要把密钥命名为 NEXT_PUBLIC_*
  • 构建时注入和运行时注入的行为取决于平台及部署方式。

错误示例:

// 客户端组件中
const secret = process.env.SESSION_SECRET

即使代码表面上没有显示密钥,也不能把服务端环境变量当成客户端可用配置。客户端真正需要的公开配置应通过明确的公开环境变量或服务器组件安全地传递。


十四、静态资源、图片和字体

静态资源通常放在 public/

public/
└── logo.svg

引用:

<img src="/logo.svg" alt="Logo" />

对于图片,next/image 可以提供尺寸管理、懒加载和响应式处理:

import Image from 'next/image'

export default function Logo() {
  return (
    <Image
      src="/logo.svg"
      alt="Logo"
      width={120}
      height={32}
    />
  )
}

外部图片域名需要在 Next.js 配置中显式允许。不要使用过宽的通配规则,否则可能把不可信远程内容纳入图片处理链。

部署时应验证:

curl -I https://example.com/logo.svg

重点检查:

  • HTTP 状态码;
  • Content-Type
  • Cache-Control
  • CDN 是否错误缓存了需要私有访问的资源;
  • 部署前缀或 basePath 是否导致资源路径错误。

十五、CSP 与生产安全边界

内容安全策略(Content Security Policy,CSP)通过响应头限制脚本、样式、图片、连接等资源来源。最小示例:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const response = NextResponse.next()

  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self'",
      "style-src 'self' 'unsafe-inline'",
      "img-src 'self' data: https:",
      "connect-src 'self' https://api.example.com",
      "object-src 'none'",
      "base-uri 'self'",
      "frame-ancestors 'none'",
    ].join('; '),
  )

  return response
}

这是示例策略,不是可以直接复制到所有项目的最终策略。风险和取舍包括:

  • 'unsafe-inline' 会降低样式策略强度;
  • 第三方分析、支付、地图和认证服务需要加入对应来源;
  • 严格脚本 CSP 可能需要 nonce 或 hash;
  • frame-ancestors 与旧的 X-Frame-Options 可以配合使用;
  • CSP 配置错误可能导致页面功能不可用,必须在真实浏览器路径验证。

上线前应先用 Content-Security-Policy-Report-Only 观察违规报告,再逐步收紧。CSP 能降低 XSS 影响,但不能替代输出编码、输入校验、依赖更新和权限控制。


十六、可观测性:不要只记录“500”

生产故障至少需要关联:

请求 ID
用户或租户 ID(遵守隐私要求)
路由
状态码
耗时
上游依赖
缓存命中/未命中
错误 digest
部署版本

错误边界中可以展示用户友好的消息:

'use client'

export default function Error({
  error,
}: {
  error: Error & { digest?: string }
}) {
  // 实际项目应调用统一日志系统,而非只 console.log
  console.error({
    message: error.message,
    digest: error.digest,
  })

  return <p>服务暂时不可用,请稍后重试。</p>
}

服务端日志不应记录密码、完整 Cookie、访问令牌或未经处理的个人敏感数据。生产诊断需要保留足够上下文,但也必须控制数据暴露。


十七、灰度发布、版本切换与回滚

Next.js 部署通常不是单个文件替换,而是一次构建产物和运行时配置的组合。灰度发布可以按以下方式进行:

用户请求
  ↓
网关按 Cookie、用户 ID 或流量比例选择版本
  ├── v1
  └── v2

灰度条件必须稳定。若同一个用户在请求之间反复落到不同版本,可能出现:

  • RSC Payload 与客户端 bundle 版本不匹配;
  • 表单 Action 指向旧版本无法识别的部署;
  • 数据库迁移已前进但旧代码无法读取;
  • Cookie 或序列化格式不兼容。

数据库迁移应尽量采用向后兼容的两阶段策略:

阶段 1:先增加新字段,旧代码仍可运行
阶段 2:部署同时支持旧字段和新字段的代码
阶段 3:完成数据回填
阶段 4:确认旧代码不再需要后,删除旧字段

不应在第一步就删除旧列,然后期待旧实例在滚动发布期间继续工作。

回滚也不只是“把镜像换回去”。需要验证:

  1. 旧版本是否支持当前数据库 schema;
  2. 缓存中是否存在新旧格式混合的数据;
  3. Server Action 或 API 合约是否兼容;
  4. 静态资源是否仍由 CDN 提供;
  5. 回滚后错误率、延迟和关键业务指标是否恢复。

十八、常见失败表现与诊断顺序

1. 页面显示旧数据

按以下顺序判断:

数据库是否真的写入?
  ↓
写操作后是否调用了正确的路径或标签失效?
  ↓
当前请求是完整刷新还是客户端导航?
  ↓
Router Cache 是否仍保留旧 RSC 片段?
  ↓
CDN 或反向代理是否缓存了旧 HTML?

不要看到旧数据就立刻把所有缓存关闭。关闭缓存可能掩盖失效逻辑错误,并使生产成本上升。

2. 客户端组件无法导入服务端模块

典型错误:

'use client'

import { db } from '@/lib/db'

修复方式不是给 db 文件加 'use client',而是把数据库访问留在服务器组件或 Server Action 中,再传递序列化结果。

3. fetch 报错但 error.tsx 没有出现

检查是否手动吞掉异常:

const response = await fetch(url)

if (!response.ok) {
  return null // 这里把故障伪装成了空数据
}

如果业务上确实允许空数据,应返回明确的领域状态;如果是系统故障,应抛出异常并保留状态码、请求 ID 和上游信息。

4. Server Action 成功但页面不更新

可能原因包括:

  • 没有失效相关路径或标签;
  • 失效的是 /products,实际读取的是带查询参数的其他路径;
  • 当前浏览器仍使用 Router Cache;
  • Action 写入的数据库与读取页面使用的数据库环境不同;
  • CDN 缓存位于 Next.js 之外;
  • 多区域部署中的缓存传播尚未完成。

5. 动态页面被错误缓存

重点检查:

  • 页面是否读取 Cookie、Header 或用户身份;
  • fetch 是否显式配置为缓存;
  • unstable_cache 的 key 是否遗漏了租户或用户 ID;
  • 反向代理是否无视 privateno-store
  • 是否把个性化结果放入公共 CDN。

十九、用一张图串起完整数据流

sequenceDiagram
    participant B as 浏览器
    participant C as CDN/反向代理
    participant N as Next.js
    participant A as Server Action
    participant D as 数据库/API
    participant R as Router Cache

    B->>C: GET /products
    C->>N: 未命中公共缓存
    N->>D: 查询商品
    D-->>N: 商品数据
    N-->>C: HTML + RSC Payload
    C-->>B: 首屏响应
    B->>R: 保存路由片段

    B->>N: 提交 Server Action
    N->>A: 执行 Action
    A->>D: 校验并写入
    D-->>A: 写入成功
    A->>N: revalidatePath('/products')
    N-->>B: Action 状态/刷新信号
    B->>N: 重新请求受影响路由
    N->>D: 读取最新数据
    D-->>N: 新结果
    N-->>B: 新 RSC Payload

关键点在于:Server Action 的写入、服务器缓存失效、浏览器 Router Cache 更新和 CDN 行为是四个相关但不同的状态变化。任何一个环节没有发生,用户都可能继续看到旧结果。


二十、架构决策的核心推导

可以用下面的顺序决定一个页面的实现方式:

第一步:页面是否依赖请求上下文?

如果依赖 Cookie、Header、用户身份或实时请求数据,通常需要动态处理;如果完全不依赖,可以考虑静态化。

第二步:数据是否允许陈旧?

设数据可接受陈旧时间为 SS

  • S=0S = 0:每次读取或写入后立即失效;
  • 0<S<0 < S < \infty:使用时间重新验证;
  • S=S = \infty:构建时或主动失效前保持缓存。

这个参数来自业务一致性,而不是来自“SSR 看起来更快”。

第三步:交互是否需要浏览器状态?

只有需要状态、事件、浏览器 API 或客户端生命周期的部分才标记为 Client Component。其余逻辑尽量保留在 Server Component。

第四步:写操作是否可重试?

如果不是幂等操作,就需要幂等键、数据库唯一约束或事务设计。按钮禁用只是用户界面优化,不能承担数据一致性责任。

第五步:部署环境是否支持依赖?

数据库驱动、文件系统、原生模块、长连接、定时任务和运行时 API 都要对照 Node 或 Edge 的能力验证。部署目标不能在代码写完后才决定。


结语

Next.js 的应用架构可以归纳为一条因果链:

文件系统路由
  → 组件树
  → RSC 服务端边界
  → HTML/RSC 渲染方式
  → 数据读取策略
  → 多层缓存与失效
  → Server Action 写入
  → 部署运行时、CDN 和监控

真正需要掌握的不是“某页面应该使用 SSR 还是 SSG”这样的二选一,而是明确:

  • 哪些代码只在服务器运行;
  • 哪些数据可以被谁缓存;
  • 写入后哪些结果必须失效;
  • 当前浏览器是否仍持有旧路由片段;
  • 请求失败时用户、日志和恢复路径分别是什么;
  • 目标部署环境是否支持整个依赖链。

当这些边界都被显式建模后,Next.js 的路由、渲染、数据、缓存和 Server Actions 就不再是互相独立的 API,而是一套可以推导、验证和回滚的系统。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。