外观
边缘渲染与 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 Runtime | Edge Runtime |
|---|---|---|
| API 兼容性 | 完整 Node.js | Web 标准 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更可靠。
常见坑
- 只看构建速度,不看运行时缓存。静态页面构建成功,不代表数据策略正确。
- 在边缘运行重依赖。SDK 体积和 Node API 兼容性问题会在部署时才暴露。
- ISR 与自托管混用。多实例没有共享缓存,导致内容随机不一致。
- 流式响应中的错误处理缺失。响应头已发送后,错误只能通过 UI 兜底。
- 忽略区域与数据源延迟。边缘节点离用户近,但离数据库可能更远,跨区域查询反而更慢。
- 环境变量在构建时被固化。客户端可见变量在构建时注入,动态环境需要服务端读取。
小结
App Router 和边缘运行时把前端应用的部署边界推进到了服务端。选择渲染模式的核心不是"哪个更先进",而是数据新鲜度、个性化程度和运行时依赖。静态优先、按需动态、边缘做轻量逻辑,是当前比较稳妥的组合。