跳到主要内容

国际化

国际化(Internationalization,简称 i18n)让你的网站能够支持多种语言和地区。Next.js 提供了灵活的路由配置来实现国际化支持。

核心概念

术语解释

Locale(语言环境) 一组语言和格式偏好的标识符,通常包含语言代码,有时还包含地区代码:

  • zh-CN:简体中文(中国)
  • zh-TW:繁体中文(台湾)
  • en-US:英语(美国)
  • ja:日语

本地化(Localization) 根据用户的语言环境调整显示内容的过程,包括翻译文本、日期格式、数字格式等。

路由策略

子路径路由

在 URL 中使用语言前缀:

  • /zh-CN/products → 中文产品页
  • /en-US/products → 英文产品页
  • /ja/products → 日语产品页

域名路由

为不同语言使用不同的域名:

  • example.com → 英文站
  • example.cn → 中文站
  • example.jp → 日文站

推荐方式

本教程推荐使用子路径路由,因为它更容易实现和维护。

基本实现

目录结构

将所有页面放在 [lang] 动态路由下:

app/
├── [lang]/
│ ├── layout.tsx
│ ├── page.tsx
│ ├── about/
│ │ └── page.tsx
│ └── blog/
│ ├── page.tsx
│ └── [slug]/
│ └── page.tsx

布局接收语言参数

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

return (
<html lang={lang}>
<body>
<nav>
<a href={`/${lang}`}>首页</a>
<a href={`/${lang}/about`}>关于</a>
</nav>
<main>{children}</main>
</body>
</html>
);
}

页面使用语言参数

// app/[lang]/page.tsx
import { getDictionary } from "@/lib/dictionary";

export default async function HomePage({
params,
}: {
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;
const dict = await getDictionary(lang);

return (
<div>
<h1>{dict.home.title}</h1>
<p>{dict.home.description}</p>
</div>
);
}

语言检测与重定向

使用中间件检测语言

// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const locales = ["zh-CN", "en-US", "ja"];
const defaultLocale = "zh-CN";

function getLocale(request: NextRequest): string {
// 1. 检查 cookie
const cookieLocale = request.cookies.get("locale")?.value;
if (cookieLocale && locales.includes(cookieLocale)) {
return cookieLocale;
}

// 2. 检查 Accept-Language 头
const acceptLanguage = request.headers.get("accept-language");
if (acceptLanguage) {
// 简单解析,生产环境可使用 negotiator 库
const preferredLocale = acceptLanguage
.split(",")
.map((lang) => lang.split(";")[0].trim())
.find((lang) => locales.includes(lang));

if (preferredLocale) {
return preferredLocale;
}
}

// 3. 返回默认语言
return defaultLocale;
}

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

// 检查路径是否已包含语言前缀
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);

if (pathnameHasLocale) {
return;
}

// 重定向到带语言前缀的路径
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;

return NextResponse.redirect(request.nextUrl);
}

export const config = {
matcher: [
// 排除内部路径
"/((?!_next|api|favicon.ico).*)",
],
};

使用 negotiator 库进行语言匹配

npm install negotiator @formatjs/intl-localematcher
// middleware.ts
import { match } from "@formatjs/intl-localematcher";
import Negotiator from "negotiator";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const locales = ["zh-CN", "en-US", "ja"];
const defaultLocale = "zh-CN";

function getLocale(request: NextRequest): string {
const headers = {
"accept-language": request.headers.get("accept-language") || "",
};
const languages = new Negotiator({ headers }).languages();
return match(languages, locales, defaultLocale);
}

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

const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);

if (pathnameHasLocale) {
return;
}

const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;

return NextResponse.redirect(request.nextUrl);
}

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

翻译管理

字典文件结构

创建不同语言的翻译文件:

// dictionaries/zh-CN.json
{
"nav": {
"home": "首页",
"about": "关于",
"blog": "博客",
"contact": "联系我们"
},
"home": {
"title": "欢迎来到我的网站",
"description": "这是一个使用 Next.js 构建的国际化网站"
},
"about": {
"title": "关于我们",
"content": "我们是一家专注于..."
}
}
// dictionaries/en-US.json
{
"nav": {
"home": "Home",
"about": "About",
"blog": "Blog",
"contact": "Contact"
},
"home": {
"title": "Welcome to My Website",
"description": "This is an internationalized website built with Next.js"
},
"about": {
"title": "About Us",
"content": "We are a company focused on..."
}
}

获取字典函数

// lib/dictionary.ts
import "server-only";

const dictionaries = {
"zh-CN": () => import("./dictionaries/zh-CN.json").then((m) => m.default),
"en-US": () => import("./dictionaries/en-US.json").then((m) => m.default),
ja: () => import("./dictionaries/ja.json").then((m) => m.default),
};

export type Locale = keyof typeof dictionaries;

export const getDictionary = async (locale: Locale) => {
// 如果请求的语言不存在,回退到默认语言
if (!dictionaries[locale]) {
return dictionaries["zh-CN"]();
}
return dictionaries[locale]();
};

在组件中使用

// components/nav.tsx
import { getDictionary, type Locale } from "@/lib/dictionary";
import Link from "next/link";

export default async function Navigation({
lang,
}: {
lang: Locale;
}) {
const dict = await getDictionary(lang);

return (
<nav className="flex gap-4">
<Link href={`/${lang}`}>{dict.nav.home}</Link>
<Link href={`/${lang}/about`}>{dict.nav.about}</Link>
<Link href={`/${lang}/blog`}>{dict.nav.blog}</Link>
<Link href={`/${lang}/contact`}>{dict.nav.contact}</Link>
</nav>
);
}

语言切换组件

// components/language-switcher.tsx
"use client";

import { useRouter, usePathname } from "next/navigation";
import type { Locale } from "@/lib/dictionary";

const locales: { code: Locale; name: string }[] = [
{ code: "zh-CN", name: "简体中文" },
{ code: "en-US", name: "English" },
{ code: "ja", name: "日本語" },
];

export default function LanguageSwitcher({
currentLocale,
}: {
currentLocale: Locale;
}) {
const router = useRouter();
const pathname = usePathname();

const handleChange = (newLocale: Locale) => {
// 替换当前路径中的语言代码
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
router.push(newPath);
};

return (
<select
value={currentLocale}
onChange={(e) => handleChange(e.target.value as Locale)}
className="border rounded px-2 py-1"
>
{locales.map((locale) => (
<option key={locale.code} value={locale.code}>
{locale.name}
</option>
))}
</select>
);
}

静态生成

使用 generateStaticParams 预生成所有语言版本的页面:

// app/[lang]/layout.tsx
export async function generateStaticParams() {
return [
{ lang: "zh-CN" },
{ lang: "en-US" },
{ lang: "ja" },
];
}

export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ lang: string }>;
}) {
const { lang } = await params;

return (
<html lang={lang}>
<body>{children}</body>
</html>
);
}

日期和数字格式化

服务端格式化

// lib/format.ts
export function formatDate(date: Date | string, locale: string): string {
const d = typeof date === "string" ? new Date(date) : date;
return d.toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
});
}

export function formatNumber(number: number, locale: string): string {
return number.toLocaleString(locale);
}

export function formatCurrency(
amount: number,
locale: string,
currency: string
): string {
return amount.toLocaleString(locale, {
style: "currency",
currency,
});
}

使用示例

// app/[lang]/blog/[slug]/page.tsx
import { getDictionary } from "@/lib/dictionary";
import { formatDate } from "@/lib/format";

export default async function BlogPost({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}) {
const { lang, slug } = await params;
const dict = await getDictionary(lang);
const post = await getPost(slug);

return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.createdAt}>
{formatDate(post.createdAt, lang)}
</time>
<div>{post.content}</div>
</article>
);
}

复数处理

不同语言的复数规则可能不同。使用 Intl.PluralRules 处理:

// lib/plural.ts
export function getPluralRule(count: number, locale: string): string {
const pluralRules = new Intl.PluralRules(locale);
return pluralRules.select(count);
}

// 使用示例
export function getMessage(count: number, locale: string): string {
const rule = getPluralRule(count, locale);
const messages = {
"zh-CN": {
one: `${count} 条评论`,
other: `${count} 条评论`,
},
"en-US": {
one: `${count} comment`,
other: `${count} comments`,
},
};

return messages[locale as keyof typeof messages][rule as keyof typeof messages["en-US"]];
}

SEO 优化

hreflang 标签

为搜索引擎指示页面的不同语言版本:

// app/[lang]/layout.tsx
import { Metadata } from "next";

const locales = ["zh-CN", "en-US", "ja"];

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string }>;
}): Promise<Metadata> {
const { lang } = await params;
const dict = await getDictionary(lang);

return {
title: dict.site.title,
description: dict.site.description,
alternates: {
canonical: `https://example.com/${lang}`,
languages: Object.fromEntries(
locales.map((l) => [l, `https://example.com/${l}`])
),
},
};
}

翻译元数据

// app/[lang]/blog/[slug]/page.tsx
import { Metadata } from "next";

export async function generateMetadata({
params,
}: {
params: Promise<{ lang: string; slug: string }>;
}): Promise<Metadata> {
const { lang, slug } = await params;
const post = await getPost(slug, lang);

return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
locale: lang,
},
};
}

最佳实践

1. 保持翻译文件扁平化

避免过深的嵌套结构,使翻译更易维护:

// 推荐
{
"blog.title": "博客",
"blog.post.create": "创建文章",
"blog.post.edit": "编辑文章"
}

// 不推荐
{
"blog": {
"post": {
"actions": {
"create": "创建文章",
"edit": "编辑文章"
}
}
}
}

2. 使用命名空间分离翻译

对于大型应用,可以按功能模块拆分翻译文件:

dictionaries/
├── zh-CN/
│ ├── common.json
│ ├── blog.json
│ └── admin.json
└── en-US/
├── common.json
├── blog.json
└── admin.json

3. 服务端优先

翻译数据应该在服务端获取,避免增加客户端 JavaScript 包体积。

4. 提供语言回退

当请求的语言不存在时,提供合理的回退机制。

小结

本章我们学习了:

  1. 国际化的核心概念和路由策略
  2. 使用中间件检测和重定向语言
  3. 翻译文件的管理和使用
  4. 语言切换组件的实现
  5. 日期、数字、货币的本地化格式化
  6. SEO 优化的 hreflang 标签
  7. 最佳实践和性能考虑

练习

  1. 创建一个支持中文和英文的简单网站
  2. 实现一个语言切换下拉菜单
  3. 添加日期和数字的本地化格式化
  4. 使用 generateStaticParams 预生成所有语言版本