Next.js 15 无痛国际化 (i18n) 实践:子路径路由、字典按需懒加载与 RTL 双向排版

在经济全球化与跨国数字协作日益深入的今天,拥有完善多语言支持(Internationalization, 简称 i18n)的产品能够打破语言壁垒,直接触达世界各地的千万级用户。然而,很多工程师对 i18n 的理解往往停留在“在前端写一个大的 JSON 字典,用 t('hello') 替换文字”这一初级层面。

在实际工业级场景中,国际化涉及一整套复杂的架构工程:SEO 友好的语言子路径重定向(如 /zh/posts vs /en/posts)、请求级异步字典按需动态加载、避免把所有语种塞入首屏 Bundle 的代码分割、TypeScript 嵌套翻译键的编译期强类型推导,以及面对阿拉伯语、希伯来语等自右向左(RTL)书写系统的排版流适配。Next.js 15 的 App Router 架构彻底革新了旧版 Pages Router 内置的静态 i18n 方案。本文将系统拆解现代 App Router 下的最佳实践体系。


1. App Router 国际化路由架构与动态路由段

在 App Router 中,官方推崇使用 动态路由段(Dynamic Segment,形如 [lang]) 来作为统领全站的顶层路由容器。

+-------------------------------------------------------------------------+
|                 App Router 多语言目录拓扑架构与路由映射                 |
+-------------------------------------------------------------------------+

app/
 ├── [lang]/                               <-- 顶级语言动态段 (zh, en, ja, ar)
 │    ├── layout.tsx                       <-- 注入 lang 与 dir 属性的根布局
 │    ├── page.tsx                         <-- 国际化首页 (/zh, /en)
 │    ├── categories/
 │    │    └── page.tsx                    <-- 分类专栏页 (/zh/categories)
 │    └── posts/
 │         └── [slug]/
 │              └── page.tsx               <-- 动态文章长文页 (/en/posts/deep-dive)
 └── middleware.ts                         <-- 智能协商首选语言并自动执行 307 重定向

1.1 顶层中间件智能语言协商(Negotiation Middleware)

当海外用户初次访问裸域名(https://example.com/)时,系统应当自动解析 HTTP 请求标头中的 Accept-Language,并结合用户的历史偏好 Cookie,秒级重定向到最佳匹配的语言子路径:

// src/middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { match as matchLocale } from '@formatjs/intl-localematcher';
import Negotiator from 'negotiator';

export const locales = ['zh', 'en', 'ja', 'ar'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'zh';

function getPreferredLocale(request: NextRequest): string {
  // 1. 优先读取用户显式切换语言时保存的 Cookie
  const savedLocale = request.cookies.get('NEXT_LOCALE')?.value;
  if (savedLocale && locales.includes(savedLocale as any)) {
    return savedLocale;
  }

  // 2. 解析浏览器的 Accept-Language 标头
  const negotiatorHeaders: Record<string, string> = {};
  request.headers.forEach((value, key) => (negotiatorHeaders[key] = value));

  const languages = new Negotiator({ headers: negotiatorHeaders }).languages();
  return matchLocale(languages, locales as unknown as string[], defaultLocale);
}

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  // 排除静态哈希资产、图标及 API 接口
  if (
    pathname.startsWith('/_next') ||
    pathname.startsWith('/api') ||
    pathname.includes('.')
  ) {
    return;
  }

  // 检查当前 URL 路径是否已经带有合法的语言前缀
  const pathnameIsMissingLocale = locales.every(
    (locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
  );

  if (pathnameIsMissingLocale) {
    const locale = getPreferredLocale(request);
    return NextResponse.redirect(
      new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, request.url)
    );
  }
}

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};

2. 异步字典按需加载与零客户端打包体积(Zero Bundle Overhead)

很多传统国际化库(如 react-i18next)会将所有语言包直接打包到前端的 JavaScript 中,导致即使是一个纯英文用户,也不得不下发繁体中文、西班牙语和日文的数千行翻译文案。 在 App Router 中,我们利用 动态 import() 与 React Server Components,让字典加载完全在服务端完成:

// src/lib/dictionaries.ts
import 'server-only'; // 🛡️ 确保字典加载逻辑绝不下发至客户端

const dictionaries = {
  zh: () => import('@/locales/zh.json').then((module) => module.default),
  en: () => import('@/locales/en.json').then((module) => module.default),
  ja: () => import('@/locales/ja.json').then((module) => module.default),
  ar: () => import('@/locales/ar.json').then((module) => module.default),
};

export const getDictionary = async (locale: 'zh' | 'en' | 'ja' | 'ar') => {
  const loader = dictionaries[locale] ?? dictionaries.zh;
  return loader();
};

2.1 服务端组件直接消费

// src/app/[lang]/page.tsx
import { getDictionary } from '@/lib/dictionaries';

export default async function Page({
  params,
}: {
  params: Promise<{ lang: 'zh' | 'en' | 'ja' | 'ar' }>;
}) {
  const { lang } = await params;
  const dict = await getDictionary(lang);

  return (
    <main className="p-8">
      <h1 className="text-4xl font-bold">{dict.home.title}</h1>
      <p className="text-slate-400 mt-2">{dict.home.subtitle}</p>
    </main>
  );
}

架构收益:无论你的语言字典庞大到几十兆字节,发往浏览器的代码体积增加量恒为 0 KB!浏览器端只接收经过翻译替换好的纯净 HTML 字符。


3. 严格编译期类型安全与嵌套翻译键推导

在大型多人协作工程中,最容易出现翻译键拼写错误(如把 dict.auth.loginButton 误写成 dict.auth.login_btn)。我们可以利用 TypeScript 的强类型推导,以主语言 zh.json 作为基准契约建立严格类型约束:

// src/types/i18n.ts
import zhDictionary from '@/locales/zh.json';

// 将主语言 JSON 对象的深层结构提取为 TypeScript 强类型
export type Dictionary = typeof zhDictionary;

// 递归展开所有合法的嵌套翻译键路径 (如 "home.title" | "auth.loginButton")
type Join<K, P> = K extends string | number
  ? P extends string | number
    ? `${K}${'' extends P ? '' : '.'}${P}`
    : never
  : never;

export type NestedKeyOf<ObjectType> = ObjectType extends object
  ? {
      [Key in keyof ObjectType & (string | number)]: ObjectType[Key] extends Array<any>
        ? Key
        : ObjectType[Key] extends object
        ? Key | Join<Key, NestedKeyOf<ObjectType[Key]>>
        : Key;
    }[keyof ObjectType & (string | number)]
  : '';

4. RTL 自右向左排版流适配与 CSS 逻辑属性

在全球化进程中,阿拉伯语(Arabic)与希伯来语(Hebrew)覆盖了中东数亿高净值用户。这类语言的阅读与排版习惯是 从右向左(RTL)。

4.1 根布局动态设置 dir 属性

// src/app/[lang]/layout.tsx
export default async function RootLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  const isRtl = lang === 'ar' || lang === 'he';

  return (
    <html lang={lang} dir={isRtl ? 'rtl' : 'ltr'}>
      <body className="bg-slate-950 text-white min-h-screen">
        {children}
      </body>
    </html>
  );
}

4.2 淘汰物理方向属性,全面拥抱 CSS 逻辑属性

在 CSS / Tailwind 编写中,严禁继续使用固定死硬的物理方向类(如 pl-4 左内边距、mr-6 右外边距)。一旦切换至 RTL 模式,这些元素位置将发生反向错位。 必须使用基于流动方向的 CSS 逻辑属性(Logical Properties):

  • 替换 ml-4 为 ms-4(Margin-Inline-Start,在 LTR 中为左,在 RTL 中自动镜像为右!);
  • 替换 pr-6 为 pe-6(Margin-Inline-End);
  • 替换 text-left 为 text-start;
  • 替换 border-l 为 border-s。

5. 多语言 SEO 架构:精准注入 hreflang 标头

为了让搜索引擎蜘蛛明确感知不同语言版本的映射关系,防止不同语种被判定为相互抄袭的重复页面,必须在 HTML 中注入 hreflang 链条:

// 在 generateMetadata 中声明多语言备用链接
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { lang, slug } = await params;

  return {
    alternates: {
      canonical: `https://example.com/${lang}/posts/${slug}`,
      languages: {
        'zh-CN': `https://example.com/zh/posts/${slug}`,
        'en-US': `https://example.com/en/posts/${slug}`,
        'ja-JP': `https://example.com/ja/posts/${slug}`,
        'ar-SA': `https://example.com/ar/posts/${slug}`,
        'x-default': `https://example.com/zh/posts/${slug}`, // 默认无匹配时的全球兜底
      },
    },
  };
}

国际化交付质量自检清单

[✓] 1. 字典按需分包: 确认客户端打包产物不包含未激活语种的任何 JSON 数据。
[✓] 2. 软导航语言同步: 在客户端页面切换语言时,URL 与 LocalStorage 偏好能够无缝同步持久化。
[✓] 3. RTL 镜像正常: 检查阿拉伯语下图标方向、侧边栏展开方向是否实现完美镜像翻转。
[✓] 4. 数字与货币格式化: 涉及金额与日期的展示,必须使用标准 Intl.NumberFormat 与 Intl.DateTimeFormat。
[✓] 5. hreflang 完整对齐: 检查所有语言页面的 alternates 标头是否形成了闭环双向引用。

通过 App Router 的顶层动态段划分、服务端字典异步按需解析与 CSS 逻辑属性的系统配合,我们能够以几乎零客户端性能损耗的极佳架构,构建出能够纵横全球市场的世界级数字体验。


6. 静态导出(Static Export)模式下的全语种预渲染矩阵

当项目配置为 output: 'export' 纯静态导出模式时,由于无法在运行时由 Node.js 或中间件动态拦截请求并重定向,所有的语言组合与动态路由必须在构建期全量通过 generateStaticParams 穷举生成。

+-------------------------------------------------------------------------+
|             多语言静态导出笛卡尔积展开 (Cartesian Product)              |
+-------------------------------------------------------------------------+

  支持的语种集合 (Locales):  ['zh', 'en', 'ja', 'ar']  (4 种)
                                   X
  所有文章 Slug 集合:        ['flutter-engine', 'rust-async', ...] (50 篇)
                                   =
  构建期生成静态页面总量:     4 x 50 = 200 个独立 HTML + Flight Payload 产物!

6.1 递归笛卡尔积实战代码

在 src/app/[lang]/posts/[slug]/page.tsx 中编写全量参数生成器:

// src/app/[lang]/posts/[slug]/page.tsx
import { locales } from '@/middleware';
import { posts } from '@/data/posts';

export async function generateStaticParams() {
  const params: Array<{ lang: string; slug: string }> = [];

  // 计算多语言与文章 slug 的完整笛卡尔积
  for (const lang of locales) {
    for (const post of posts) {
      params.push({
        lang,
        slug: post.slug,
      });
    }
  }

  return params;
}

生成产物目录结构:

out/
 ├── zh/
 │    ├── posts/
 │    │    └── flutter-engine.html
 ├── en/
 │    ├── posts/
 │    │    └── flutter-engine.html
 └── ja/
      └── posts/
           └── flutter-engine.html

所有语种页面全部提前固化为闪电般极速的静态文件,配合 Caddy 或 Cloudflare Pages 的全球 CDN 边缘节点,海外用户即使身在欧美或日韩,也能以 15ms 的极致首字节时间(TTFB)瞬间加载对应语种的技术正文。