next-intl 的服务端与客户端协同机制

3 min read
Zekari
Next.jsi18nnext-intlApp RouterServer Components

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:默认语言不显示前缀

2 min read

理解 next-intl 中 localePrefix 配置的设计哲学,以及为什么默认语言不应在 URL 中显现。

Next.jsi18n
Read More

动态元数据生成:让机器读懂你的页面

2 min read

generateMetadata 不只是填写表单。它决定了搜索引擎、社交平台、AI 系统如何理解和呈现你的内容。

Next.jsSEO
Read More

继承基础配置

2 min read

配置不需要重复书写。继承机制让每个层次只表达自己的差异。

TypeScript配置管理
Read More

Purikura的页面系统

3 min read

通过五层分层继承复用架构,实现零代码修改的页面生成系统。从类型定义到页面渲染,每一层专注单一职责,实现真正的数据驱动开发。

架构设计React
Read More

Google Fonts 官方集成

2 min read

Next.js 提供了 next/font 模块,让字体加载变得简单且性能优化。Google Fonts 是最直接的商用免费字体选择。

Next.js字体
Read More

让错误浮现

1 min read

Next.js 构建悬挂问题的根源不在工具,而在掩盖。严格类型检查不是负担,而是质量的守护者。

TypeScriptNext.js
Read More

减少 Next.js 启动时的工作量

2 min read

开发服务器启动缓慢不是偶然。它在做的事太多了。

Next.js性能优化
Read More

用静态导出控制视口

2 min read

Next.js 中的视口配置通过静态导出模式定义页面初始状态,理解其背后的设计约束能够更好地控制用户体验边界。

Next.js视口
Read More