消灭 Next.js 冷启动:从动态 SSR 到全量 SSG
症状
每次页面导航都有一段固定的卡顿。不是网络慢——香港到 Vercel 免费版边缘节点本来就有延迟。这是另一种慢:即使是几周没变过的页面,首字节也要等 1–4 秒才到。
Vercel 面板确认了。响应头说明了一切:
x-vercel-cache: MISS
每次请求都在打 Serverless Function。没有任何东西被缓存。
找到根因
博客用 next-intl 做国际化。原来的方案是 cookie 检测:middleware 读写 NEXT_LOCALE cookie,i18n/request.ts 调用 cookies() 取值。
// ❌ 原来的 i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { cookies } from 'next/headers';
export default getRequestConfig(async () => {
const locale = cookies().get('NEXT_LOCALE')?.value ?? 'en';
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});看起来没什么问题。但 cookies() 是 Next.js 的动态函数。在渲染链路的任何地方调用它——包括 getRequestConfig 内部——等于告诉框架:"这个页面的输出依赖当前请求。"
Next.js 随即把所有调用 getTranslations() 的页面都标记为 dynamic,完全退出静态生成。构建产物里全是 ƒ:
Route Size First Load JS
┌ ƒ /en 4.2 kB 142 kB
├ ƒ /en/blog 3.1 kB 138 kB
├ ƒ /en/blog/[slug] 6.4 kB 156 kB
├ ƒ /en/about 2.8 kB 135 kB
└ ƒ /en/portfolio 3.3 kB 140 kB
每个 ƒ 都是一个 Serverless Function。每次访问都要冷启动一个容器、读 cookie、渲染 HTML、再销毁。Vercel 免费版没有持久计算,冷启动是常态。
结构性修复
解法是把 locale 从请求里移到 URL 里。不再问"用户 cookie 里写的是哪个语言?",而是问"这个 URL 路径是哪个语言的?"
路由结构从:
src/app/
page.tsx ← 单一路由,运行时读 cookie 判断 locale
blog/
page.tsx
改成:
src/app/
[locale]/
page.tsx ← 每个 locale 一条独立静态路由,构建时预渲染
blog/
page.tsx
第一步:定义路由
// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
locales: ['en', 'zh'],
defaultLocale: 'en',
localePrefix: 'always', // 所有路由都带前缀:/en/... 和 /zh/...
});第二步:request.ts 改从路由参数读取
// ✅ 新的 i18n/request.ts——没有 cookies(),没有 headers()
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale; // 来自 [locale] 路由段
if (!locale || !routing.locales.includes(locale as 'en' | 'zh')) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../messages/${locale}.json`)).default,
};
});requestLocale 在构建时由 URL 段决定,不读任何运行时数据,不触发动态标记。
第三步:每个页面调用 setRequestLocale 并生成静态参数
// src/app/[locale]/blog/page.tsx
import { setRequestLocale } from 'next-intl/server';
import { locales } from '@/i18n/config';
interface PageProps {
params: Promise<{ locale: string }>;
}
// 告诉 Next.js 构建时要预渲染哪些 [locale] 值
export async function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function BlogPage({ params }: PageProps) {
const { locale } = await params;
setRequestLocale(locale); // 标记这次渲染是静态安全的
// ... 渲染页面
}有多个动态段的页面(比如博客文章)需要覆盖所有维度的组合:
// src/app/[locale]/blog/[slug]/page.tsx
export async function generateStaticParams() {
// 预渲染每一个 locale × slug 的组合
return locales.flatMap((locale) =>
getAllSlugs(locale).map((slug) => ({ locale, slug }))
);
}第四步:全站导航改用 locale-aware 工具函数
原生的 next/link 和 next/navigation 不知道 locale 前缀。用 next-intl 的导航工具替换:
// src/i18n/navigation.ts
import { createNavigation } from 'next-intl/navigation';
import { routing } from './routing';
export const { Link, redirect, usePathname, useRouter } =
createNavigation(routing);// 组件里——href 写不带前缀的路径,locale 自动补全
import { Link } from '@/i18n/navigation';
<Link href="/blog">Blog</Link>
// 根据当前 locale 自动渲染成 /en/blog 或 /zh/blog第五步:middleware 处理根路径重定向
访问 / 的用户要被引导到 /en 或 /zh。middleware 读 Accept-Language 和 NEXT_LOCALE cookie(记住上次选择),然后重定向——这只是一次跳转,不是逐页渲染:
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { locales, defaultLocale } from '@/i18n/config';
export default createMiddleware({
locales,
defaultLocale,
localePrefix: 'always',
localeCookie: true, // 记住上次选择
});
export const config = {
matcher: [
'/((?!api|og|feed.xml|feed-zh.xml|sitemap.xml|robots.txt|_next|.*\\..*).*)',
],
};结果
重构之后,构建产物完全变了:
Route Size First Load JS
┌ ○ /en 4.2 kB 142 kB
├ ○ /en/blog 3.1 kB 138 kB
├ ● /en/blog/[slug] 6.4 kB 156 kB
│ ├ /en/blog/frontend-performance-optimization
│ ├ /en/blog/state-management-in-2024
│ └ /en/blog/building-modern-ai-interfaces
├ ○ /en/about 2.8 kB 135 kB
└ ○ /en/portfolio 3.3 kB 140 kB
○ 是完全静态。● 是有预渲染页面的静态路由。没有任何 ƒ。
响应头变成了:
x-vercel-cache: HIT
页面直接从 Vercel CDN 边缘节点返回,完全不经过 Serverless Function。TTFB 从 1–4s 降到缓存命中时的 100ms 以内。
这个约束意味着什么
静态优先架构有一条硬规则:渲染链路里不能出现 cookies()、headers()、或 next-intl 服务端工具的 getLocale()。任何一个都会把页面打回动态 SSR。
具体来说:
i18n/request.ts只能用requestLocale,不能碰cookies()或headers()- 每个调用
getTranslations()的页面,之前必须先调用setRequestLocale(locale) - 原来内部调用
getLocale()的数据获取工具,必须改成接受显式locale参数
locale 始终可以从路由参数里拿到,不需要在运行时从请求里读取。把它挡在请求链路之外,就是让一切保持静态的关键。
剩余延迟
重构之后,缓存命中的 TTFB 稳定快速。首次命中(CDN 边缘冷缓存或刚部署之后)还有一些延迟——但这已经是用户到最近边缘节点的网络层 RTT,不再是 Serverless Function 冷启动。
这是本质上不同的问题。你可以通过换一个边缘节点更靠近用户的服务商来解决,或者付费用 Vercel Pro 更宽的边缘网络。但换 CDN 解决不了 Serverless 冷启动——你得先把 Serverless Function 消灭掉。