国际化
国际化(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. 提供语言回退
当请求的语言不存在时,提供合理的回退机制。
小结
本章我们学习了:
- 国际化的核心概念和路由策略
- 使用中间件检测和重定向语言
- 翻译文件的管理和使用
- 语言切换组件的实现
- 日期、数字、货币的本地化格式化
- SEO 优化的 hreflang 标签
- 最佳实践和性能考虑
练习
- 创建一个支持中文和英文的简单网站
- 实现一个语言切换下拉菜单
- 添加日期和数字的本地化格式化
- 使用 generateStaticParams 预生成所有语言版本