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)瞬间加载对应语种的技术正文。