在Next项目中实现i18n国际化
在Next.js项目中使用i18n,我看了一下网上有很多有用的方案,不过看再多终归比不上自己亲自试一试,果然试试就有坑,期间遇到过一些问题,特此总结。
1、库的选择
首先官方推荐的主流几种是 next-i18next,next-translate,next-intl ,next-i18next和next-translate功能多,适合大型项目,考虑到我只是小项目,当然选择next-intl,上手简单,适合的才是最好的。
2、安装
npm i react-intl
3、实现
创建下面这些文件:
-
src/i18n/locales.ts: 声明支持的locales与defaultLocale。export const locales = ['zh', 'en'] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = 'zh'; -
src/i18n/messages/*.json: 每个语言的翻译文件,注意我这里是平铺键。比如我的就是zh.json和en.json
// en.json { "nav.blog.label": "Blog", "nav.project.label": "Projects", } // zh.json { "nav.blog.label": "博客", "nav.project.label": "项目", } -
src/i18n/request.ts: next-intl 的getRequestConfig导出 —— 负责决定locale、加载messages(动态导入)并将平铺键转换为嵌套对象。import { getRequestConfig } from 'next-intl/server'; import { locales, defaultLocale, type Locale } from './locales'; export async function loadMessages(locale: Locale) { // 使用动态导入而不是文件系统读取,确保文件被打包 const messages = await import(`./messages/${locale}.json`); const flat = messages.default as Record<string, string>; // 将 "a.b.c": "..." 形式转换为嵌套对象结构,兼容 next-intl 默认验证 interface NestedMessages { [key: string]: string | NestedMessages; } const nest = (input: Record<string, string>): NestedMessages => { const out: NestedMessages = {}; for (const [key, value] of Object.entries(input)) { const parts = key.split('.'); let cur: NestedMessages = out; for (let i = 0; i < parts.length; i++) { const part = parts[i]; const isLast = i === parts.length - 1; if (!isLast) { if (cur[part] && typeof cur[part] !== 'object') { cur[part] = { label: cur[part] as string }; } cur[part] = (cur[part] as NestedMessages) || {}; cur = cur[part] as NestedMessages; } else { if (cur[part] && typeof cur[part] === 'object') { (cur[part] as NestedMessages).label = value; } else { cur[part] = value; } } } } return out; }; return nest(flat); } export default getRequestConfig(async ({ requestLocale }) => { // 优先使用 requestLocale (从 middleware header 传递) let localeValue = (await requestLocale) as string | undefined; // 如果没有 requestLocale,尝试从 headers/cookies 读取 if (!localeValue || !locales.includes(localeValue as Locale)) { // 动态导入 headers 和 cookies (仅在需要时) const { cookies, headers } = await import('next/headers'); const cookieStore = await cookies(); const headersList = await headers(); // 1. 尝试从 cookie 读取 const cookieLocale = cookieStore.get('locale')?.value as Locale | undefined; if (cookieLocale && locales.includes(cookieLocale)) { localeValue = cookieLocale; } else { // 2. 尝试从 middleware 注入的 header 读取 const headerLocale = headersList.get('x-next-intl-locale') as Locale | undefined; if (headerLocale && locales.includes(headerLocale)) { localeValue = headerLocale; } } } const resolved = localeValue && locales.includes(localeValue as Locale) ? (localeValue as Locale) : defaultLocale; return { locale: resolved, messages: await loadMessages(resolved), }; }); -
src/i18n/format.ts: 本地化辅助函数(日期/数字格式化)的统一封装。import { Locale, defaultLocale } from './locales'; export function formatDate(date: Date | string | number, locale?: Locale, options?: Intl.DateTimeFormatOptions) { const d = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date; const loc = locale || defaultLocale; return new Intl.DateTimeFormat(loc, { year: 'numeric', month: '2-digit', day: '2-digit', ...options, }).format(d); } // 自行补充所需
注意点:
1、在 middleware.ts 中计算并注入 x-next-intl-locale header,同时设置 locale cookie。这样服务端(包括边缘或 server components)可读取 header 或 cookie 来决议语言。然后getRequestConfig 的默认导出(见 src/i18n/request.ts)会被 next-intl 在服务端调用,用来返回 { locale, messages }。
// 计算 locale:cookie > Accept-Language > default
let locale = req.cookies.get('locale')?.value as Locale | undefined;
if (!locale || !locales.includes(locale)) {
const acceptRaw = req.headers.get('accept-language');
if (acceptRaw) {
const segments: string[] = acceptRaw.split(',').map((s: string) => s.trim().toLowerCase());
const found = segments
.map((seg) => seg.split(';')[0])
.map((seg) => seg.split('-')[0] as Locale)
.find((seg) => locales.includes(seg));
locale = found || defaultLocale;
} else {
locale = defaultLocale;
}
}
// 若存在临时参数 _lang/_ts,清理后重定向到干净 URL
if (searchParams.has('_lang') || searchParams.has('_ts')) {
const clean = req.nextUrl.clone();
clean.searchParams.delete('_lang');
clean.searchParams.delete('_ts');
const redirectRes = NextResponse.redirect(clean);
return redirectRes;
}
// 克隆原始请求头并附加 locale,避免丢失其它必要头
const requestHeaders = new Headers(req.headers);
requestHeaders.set('x-next-intl-locale', locale);
const res = NextResponse.next({
request: {
headers: requestHeaders,
},
});
// 若缺少 locale cookie 则写入,保持后续刷新一致性
if (!req.cookies.get('locale')?.value) {
res.cookies.set({
name: 'locale',
value: locale,
path: '/',
sameSite: 'lax',
httpOnly: false,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 365,
});
}
2、项目这里用的是平铺键,优点就是易读、翻译流程和动化友好、易审查。缺点就是运行时需转换、不如嵌套直观、易产生命名冲突(需约束),request.ts里面的nest 函数就是把平铺转成嵌套对象后再交给 next-intl。因此开发和翻译仍可用平铺文件存储与编辑,而运行时得到 next-intl 需要的结构。即把 {'a.b': 'x'} 转换为 { a: { b: 'x' } },同时处理键冲突(字符串 vs 对象)。使用如下:
// 错误的方式:嵌套了
{
nav.blog,
nav.blog.label,
}
// 正确的方式:平铺并行
{
nav.blog.btn,
nav.blog.label,
}
4、使用
在 app-layout 中初始化客户端 provider:
- 在
src/app/layout.tsx(Server Component)中,通过cookies()读取locale,再调用loadMessages(locale)获取消息,并在NextIntlClientProvider中传入:
// layout.tsx (简化)
import { cookies } from 'next/headers';
import { NextIntlClientProvider } from 'next-intl';
import { loadMessages } from '@/i18n/request';
export default async function RootLayout({ children }) {
const cookieStore = await cookies();
const locale = cookieStore.get('locale')?.value ?? 'zh';
const messages = await loadMessages(locale);
return (
<html lang={locale}>
<body>
<NextIntlClientProvider locale={locale} messages={messages}>
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
客户端使用方式:
- 在 client components 中使用
useTranslations()获取翻译函数,useLocale()获取当前语言。示例:
import { useTranslations, useLocale } from 'next-intl';
import type { Locale } from '@/i18n/locales';
export default function MyComp() {
const t = useTranslations();
const locale = useLocale();
return (
<div>
{t('hero.title')}
{formatDate(post.createdAt, locale as Locale)}
</div>
);
}
服务端组件使用方式:
import { getTranslations } from 'next-intl/server';
export default async function DashboardPage() {
const t = await getTranslations();
// ...
}
切换语言:
写一个客户端组件通过写入 locale cookie 并刷新页面来切换,middleware 会在下次请求注入正确的 header 并在 server 端被 getRequestConfig 使用。
'use client';
import React from 'react';
import { useLocale } from 'next-intl';
import { locales, type Locale } from '@/i18n/locales';
export default function LanguageSwitcherSidebar() {
const current = useLocale();
function handleClick() {
const idx = locales.indexOf(current as Locale);
const next = locales[(idx + 1) % locales.length] as Locale;
try {
document.cookie = `locale=${next};path=/;max-age=31536000`;
} catch {}
// 切换后刷新页面以应用语言
if (typeof window !== 'undefined') window.location.reload();
}
const label = (current || locales[0]).toUpperCase();
return (
<button
aria-label="Switch language"
title="切换语言"
onClick={handleClick}
>
{label}
</button>
);
}
5、易错点强调
(1) 不要在平铺键里面嵌套,就像我前面示例的错误写法不要尝试,不然会报错;
(2) 在Next项目里面,客户端组件用useTranslations,服务端组件用getTranslations,不要弄混不然也会有bug;
(3) 在request.ts里面使用动态引入是防止部署的时候Vercel识别不了。