next-intl 的服务端与客户端协同机制
Next.js App Router 的服务端组件默认是静态渲染的。这意味着页面在构建时生成,不在请求时运行。
这对国际化是个问题。语言是请求时才能确定的信息。URL 路径、用户偏好、浏览器设置——这些都是运行时数据。
next-intl 需要在两个矛盾的要求之间找到平衡:保持静态渲染的性能优势,同时支持动态的语言切换。
静态渲染的困境
// app/[locale]/page.tsx
export default function HomePage() {
// 这段代码在构建时运行
// 此时不知道用户会请求哪个 locale
return <h1>Welcome</h1>;
}
静态生成时,Next.js 需要为所有可能的 locale 预先生成页面。但它不知道当前在生成哪个 locale 的版本。
这就是 unstable_setRequestLocale 存在的原因。
unstable_setRequestLocale 的角色
import { unstable_setRequestLocale } from 'next-intl/server';
export default function HomePage({ params: { locale } }: Props) {
unstable_setRequestLocale(locale);
return <h1>Welcome</h1>;
}
这个函数做了什么?它告诉 next-intl:「现在正在渲染这个 locale 的版本。」
这是显式的同步点。在调用之前,next-intl 处于「不确定」状态。调用之后,所有依赖 locale 的逻辑都有了明确的上下文。
为什么需要显式调用?因为 Next.js 的静态生成是并行的。同一时间可能在生成多个 locale 的页面。如果依赖全局状态,会产生竞态条件。
unstable_setRequestLocale 通过 React 的 cache 机制,为当前渲染上下文设置 locale。这是线程安全的。
unstable_ 前缀表明这个 API 可能在未来改变。
这不是因为实现不稳定,而是因为 Next.js 团队可能会提供更好的原生方案。当 Next.js 提供官方的「静态生成上下文传递」机制时,这个函数可能被替代。
在那之前,它是必需的。
服务端到客户端的消息传递
服务端组件可以直接读取翻译文件。客户端组件不行。
客户端组件需要通过网络接收翻译内容。但每个客户端组件单独请求翻译是低效的。
next-intl 使用 Provider 模式一次性传递所有消息。
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
export default async function RootLayout({
children,
params: { locale }
}: Props) {
unstable_setRequestLocale(locale);
// 服务端读取翻译文件
const messages = await getMessages();
return (
<html lang={locale}>
<body>
{/* 将消息注入客户端 */}
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
这个流程有三个关键点。
第一,服务端优先。 getMessages() 在服务端运行。它直接读取文件系统,没有网络开销。
第二,一次传递。 所有翻译消息作为 props 传递给 Provider。客户端组件从 Provider 读取,不需要额外请求。
第三,序列化边界。 消息从服务端传到客户端时,经过序列化。复杂对象(如函数)会丢失。翻译内容必须是纯数据。
Provider 的嵌套规则
Next.js App Router 的布局是嵌套的。每个层级都可以有自己的 layout。
next-intl 的 Provider 应该放在哪里?
// ✅ 正确:放在根 layout
// app/[locale]/layout.tsx
export default async function RootLayout({ children, params: { locale } }: Props) {
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{children}
</NextIntlClientProvider>
);
}
// ❌ 错误:在每个页面重复 Provider
// app/[locale]/about/page.tsx
export default function AboutPage() {
const messages = useMessages(); // 错误:没有 Provider
return <div>About</div>;
}
Provider 只需要一个,放在最外层。
为什么?因为 Provider 的本质是依赖注入。它建立了一个「消息可用」的上下文。内层组件通过这个上下文访问翻译。
多个 Provider 不会提升性能。反而会增加包体积(客户端要加载多份相同的消息)。
相关概念可以参考 dependency-injection。
客户端组件的使用模式
客户端组件通过 hooks 访问翻译。
'use client';
import { useTranslations } from 'next-intl';
export function LoginButton() {
const t = useTranslations('auth');
return <button>{t('login')}</button>;
}
useTranslations 从 Provider 读取消息。它需要运行在 Provider 子树中。
如果在 Provider 外使用,会抛出错误。这是故意的。显式失败比静默失败好。
消息的作用域管理
翻译文件通常是嵌套的 JSON。
{
"auth": {
"login": "Login",
"logout": "Logout"
},
"nav": {
"home": "Home",
"about": "About"
}
}
useTranslations('auth') 返回 auth 作用域的翻译函数。调用 t('login') 实际访问 auth.login。
这个作用域机制是关键。它让组件只声明自己需要的翻译,而不是整个翻译文件。
// 清晰的依赖声明
function AuthForm() {
const t = useTranslations('auth');
// 这个组件只依赖 auth 作用域
}
function Navigation() {
const t = useTranslations('nav');
// 这个组件只依赖 nav 作用域
}
当翻译文件变大时,作用域让你知道哪些组件会受影响。修改 nav 作用域不会影响 AuthForm。
这不是技术约束,而是心智模型。代码应该表达依赖关系。
类型安全的翻译
next-intl 支持 TypeScript 类型推断。
// messages/en.json
{
"auth": {
"welcome": "Welcome, {name}!"
}
}
// 组件中
const t = useTranslations('auth');
// ✅ 正确
t('welcome', { name: 'Alice' });
// ❌ 错误:缺少参数
t('welcome');
// ❌ 错误:参数类型不匹配
t('welcome', { name: 123 });
类型安全需要配置。但一旦配置好,编译器会检查所有翻译调用。
拼写错误、缺少参数、参数类型不匹配——这些问题在构建时发现,而不是运行时。
时序:从请求到渲染
💡 Click the maximize icon to view in fullscreen
这个流程是单向的。消息从服务端流向客户端,不回流。
中间件识别 locale。Layout 设置上下文并加载消息。Provider 传递消息到客户端。组件使用翻译。
每个环节都有明确的职责。中间件不加载消息,只识别语言。Layout 不渲染翻译,只传递数据。组件不关心消息来源,只使用翻译。
这种分离让代码可测试。你可以独立测试每个环节。
代码组织的最佳实践
统一的入口。 所有 locale 相关配置集中在一处。
// i18n/config.ts
export const locales = ['en', 'zh', 'ja'] as const;
export const defaultLocale = 'en';
export type Locale = typeof locales[number];
单一的 Provider。 只在根 layout 使用 Provider。
// app/[locale]/layout.tsx
export default async function RootLayout(props: Props) {
unstable_setRequestLocale(props.params.locale);
const messages = await getMessages();
return (
<NextIntlClientProvider messages={messages}>
{props.children}
</NextIntlClientProvider>
);
}
明确的作用域。 每个组件声明自己的翻译作用域。
function ProductCard() {
const t = useTranslations('product');
// 依赖关系清晰
}
这不是约定,而是代码的自我解释。未来的维护者看到这段代码,立刻知道这个组件依赖哪些翻译。
最后
next-intl 的实现机制反映了 Next.js App Router 的核心约束:服务端优先,客户端按需。
服务端负责数据获取(读取翻译文件)。客户端负责交互(使用翻译)。Provider 是两者之间的桥梁。
unstable_setRequestLocale 不是技术债,而是对静态渲染模型的适配。它让 next-intl 在保持性能的同时支持动态语言。
理解这个机制,不只是为了使用 next-intl。而是为了理解 App Router 中「数据如何在服务端和客户端之间流动」。
这个模式适用于所有需要在服务端准备、客户端使用的数据。国际化只是一个例子。
Related Posts
Articles you might also find interesting
next-intl localePrefix:默认语言不显示前缀
理解 next-intl 中 localePrefix 配置的设计哲学,以及为什么默认语言不应在 URL 中显现。
动态元数据生成:让机器读懂你的页面
generateMetadata 不只是填写表单。它决定了搜索引擎、社交平台、AI 系统如何理解和呈现你的内容。
继承基础配置
配置不需要重复书写。继承机制让每个层次只表达自己的差异。
Purikura的页面系统
通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。
Google Fonts 官方集成
Next.js 提供了 next/font 模块,让字体加载变得简单且性能优化。Google Fonts 是最直接的商用免费字体选择。
让错误浮现
Next.js 构建悬挂问题的根源不在工具,而在掩盖。严格类型检查不是负担,而是质量的守护者。
减少 Next.js 启动时的工作量
开发服务器启动缓慢不是偶然。它在做的事太多了。
用静态导出控制视口
Next.js 中的视口配置通过静态导出模式定义页面初始状态,理解其背后的设计约束能够更好地控制用户体验边界。