Skip to content

边缘渲染与 Serverless:Next.js App Router 实战 ​

前端架构的边界正在向服务端移动。Next.js App Router 把渲染、数据获取和缓存都放进了框架层,部署平台则把这些能力分布到离用户更近的边缘节点。

这带来一个现实问题:哪些页面应该静态生成,哪些应该动态渲染,哪些适合放到边缘? 答案取决于数据新鲜度、个性化程度和运行时依赖。

渲染模式先分清 ​

模式生成时机数据新鲜度适用场景
CSR浏览器运行时实时强交互后台、无需 SEO 的页面
SSR每次请求实时个性化页面、登录后内容
SSG构建时构建时博客、文档、营销页
ISR构建时 + 后台增量更新可配置商品列表、内容站
Streaming请求时逐步输出实时首屏快、部分数据较慢的页面
PPR静态外壳 + 动态洞混合大部分静态、局部个性化的页面

App Router 默认使用 Server Components,页面可以是 async 函数,直接在服务端取数。渲染模式由缓存配置和运行时 API 的使用情况决定。

数据缓存与重新验证 ​

Next.js 的 App Router 有几层缓存,理解它们是排查"为什么数据没更新"的前提:

  • Request Memoization:同一次渲染中相同的 fetch 只执行一次。
  • Data Cache:跨请求缓存 fetch 结果,可通过 revalidate 和 tags 控制。
  • Full Route Cache:静态路由的完整渲染结果。
  • Router Cache:客户端路由预取与回退缓存。
tsx
// app/products/page.tsx
async function getProducts() {
  const response = await fetch('https://api.example.com/products', {
    next: {
      revalidate: 60,
      tags: ['products']
    }
  })

  if (!response.ok) {
    throw new Error(`Failed to fetch products: ${response.status}`)
  }

  return response.json() as Promise<Product[]>
}

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

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

revalidate: 60 表示 60 秒后后台重新验证;tags 用于精确失效。后台更新商品后调用:

ts
// app/api/products/route.ts
import { revalidateTag } from 'next/cache'
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const body = await request.json()
  await db.product.create(body)
  revalidateTag('products')

  return NextResponse.json({ ok: true }, { status: 201 })
}

这比设置一个很短的 TTL 更精确:只有数据变更时才让缓存失效。

静态生成与增量更新 ​

内容型页面适合构建时生成。使用 generateStaticParams 预生成路由:

tsx
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await getPostSlugs()
  return posts.map((slug) => ({ slug }))
}

export const revalidate = 3600

export default async function PostPage({
  params
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const post = await getPost(slug)

  return (
    <article>
      <h1>{post.title}</h1>
      <div>{post.content}</div>
    </article>
  )
}

revalidate = 3600 让页面每小时最多重新生成一次。访问者始终拿到静态 HTML,后台负责更新,兼顾速度和新鲜度。

ISR 在自托管环境需要共享缓存和持久化存储。如果部署在多个实例上,重新验证必须能让所有实例看到同一份结果,否则会出现内容不一致。

Streaming 与 Suspense ​

流式渲染让页面先输出静态部分,慢数据通过 Suspense 占位后补:

tsx
import { Suspense } from 'react'

export default function ProductPage({ params }: { params: Promise<{ id: string }> }) {
  return (
    <main>
      <ProductInfo params={params} />
      <Suspense fallback={<ReviewsSkeleton />}>
        <Reviews params={params} />
      </Suspense>
    </main>
  )
}

流式输出的价值是更早的 LCP 和 TTFB,但要注意:

  • 响应头一旦发送,后续渲染出错无法再改状态码,只能由客户端兜底。
  • 多个 Suspense 边界会并行加载,不要把所有内容都包在一个大边界里。
  • 骨架屏要预留真实尺寸,否则会引入 CLS。

Edge Runtime:把执行推到离用户更近的地方 ​

边缘运行时基于 Web 标准 API,部署在 CDN 节点上。适合:

  • 地理路由、A/B 分流。
  • 鉴权校验、重定向。
  • 轻量数据聚合、个性化文案。
  • 图片和内容变换。

在 Route Handler 中声明:

ts
// app/api/geo/route.ts
export const runtime = 'edge'

export async function GET(request: Request) {
  const country = request.headers.get('x-vercel-ip-country') ?? 'unknown'
  const city = request.headers.get('x-vercel-ip-city') ?? 'unknown'

  return Response.json({ country, city })
}

Middleware 默认运行在边缘,常用来做登录拦截:

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

export function middleware(request: NextRequest) {
  const session = request.cookies.get('session')?.value

  if (!session) {
    const loginUrl = new URL('/login', request.url)
    loginUrl.searchParams.set('from', request.nextUrl.pathname)
    return NextResponse.redirect(loginUrl)
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*']
}

边缘运行的典型约束:

  • 不能使用 fs、原生模块、部分 Node.js API。
  • 包体积和执行时间有限制。
  • 数据库连接通常需要通过 HTTP 驱动或连接池代理,不能直接开 TCP 长连接。
  • 调试和日志能力弱于 Node 运行时。
维度Node RuntimeEdge Runtime
API 兼容性完整 Node.jsWeb 标准 API 子集
冷启动相对较慢通常更快
地理位置区域部署全球边缘节点
适合任务SSR、数据库、复杂依赖鉴权、路由、轻量聚合
限制需要管理服务器包体积、CPU、API 限制

缓存与个性化的冲突 ​

边缘缓存最危险的问题是:把个性化内容缓存成公共内容。

常见错误:

  • 在静态页面中直接读取 Cookie 并渲染用户名。
  • 缓存键没有包含语言、地区、登录态。
  • Route Handler 使用默认缓存,却返回了带用户信息的数据。

解决思路:

  • 公共部分静态化,个性化部分下沉到客户端组件或动态 Suspense 边界。
  • 使用 cookies()、headers() 的页面自动进入动态渲染,不要强行静态化。
  • 需要按用户缓存时,缓存键必须包含用户或租户标识,并设置合理的 TTL。
  • 敏感接口设置 Cache-Control: private, no-store。

Next.js 15 调整了缓存默认值:fetch 默认不再缓存,Route Handler 默认也不缓存。升级时需要逐页检查原有依赖隐式缓存的逻辑,显式声明 cache: 'force-cache' 或 revalidate 更可靠。

常见坑 ​

  1. 只看构建速度,不看运行时缓存。静态页面构建成功,不代表数据策略正确。
  2. 在边缘运行重依赖。SDK 体积和 Node API 兼容性问题会在部署时才暴露。
  3. ISR 与自托管混用。多实例没有共享缓存,导致内容随机不一致。
  4. 流式响应中的错误处理缺失。响应头已发送后,错误只能通过 UI 兜底。
  5. 忽略区域与数据源延迟。边缘节点离用户近,但离数据库可能更远,跨区域查询反而更慢。
  6. 环境变量在构建时被固化。客户端可见变量在构建时注入,动态环境需要服务端读取。

小结 ​

App Router 和边缘运行时把前端应用的部署边界推进到了服务端。选择渲染模式的核心不是"哪个更先进",而是数据新鲜度、个性化程度和运行时依赖。静态优先、按需动态、边缘做轻量逻辑,是当前比较稳妥的组合。

基于 VitePress 构建 · 持续更新中