最近开发的系统中需要支持多语言,因此调研了一下 Next.js 的 App Route 中如何实现 i18n。
配置
1. 安装依赖
pnpm install i18next react-i18next i18next-resources-to-backend2. 创建翻译文件
我们假设项目中需要支持的语言有
en 和 zh,那么首先在 public/locales/[language] 目录下创建 /common.jsonpublic/locales/en/common.json
public/locales/zh/common.json然后在
public/locales/en/common.json 中添加需要支持的语言的翻译内容,例如:{
"hello": "Hello"
}在
public/locales/zh/common.json 中添加需要支持的语言的翻译内容,例如:{
"hello": "你好"
}3. 创建 i18n 配置
我们在
app 目录下创建 lib/i18n 目录,并在其中创建以下几个文件:app/lib/i18n/server.ts
app/lib/i18n/client.ts
app/lib/i18n/middleware.ts
app/lib/i18n/settings.ts其内容如下:
server.ts
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import { getOptions } from './settings'
import { i18n } from 'i18next'
const initI18next = async (lng: string, ns: string): Promise<i18n> => {
const i18nInstance = createInstance()
await i18nInstance
.use(initReactI18next)
.use(resourcesToBackend((language: string, namespace: string) =>
import(`../../../public/locales/${language}/${namespace}.json`)
))
.init(getOptions(lng, ns))
return i18nInstance
}
export async function useTranslation(
lng: string,
ns: string,
options: { keyPrefix?: string } = {}
) {
const i18nextInstance = await initI18next(lng, ns)
return {
t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
i18n: i18nextInstance
}
}client.ts
'use client';
import { useEffect, useState } from 'react';
import i18next from 'i18next';
import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next';
import { useCookies } from 'react-cookie';
import resourcesToBackend from 'i18next-resources-to-backend';
import LanguageDetector from 'i18next-browser-languagedetector';
import { getOptions, languages, cookieName } from './settings';
const runsOnServerSide = typeof window === 'undefined';
//
i18next
.use(initReactI18next)
.use(LanguageDetector)
.use(resourcesToBackend((language: string, namespace: string) => import(`../../../public/locales/${language}/${namespace}.json`)))
.init({
...getOptions(),
lng: undefined, // let detect the language on client side
detection: {
order: ['path', 'htmlTag', 'cookie', 'navigator'],
},
preload: runsOnServerSide ? languages : [],
});
export function useTranslation(lng: string, ns: string, options: { keyPrefix?: string } = {}) {
const [cookies, setCookie] = useCookies([cookieName]);
const ret = useTranslationOrg(ns, options);
const { i18n } = ret;
if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
i18n.changeLanguage(lng);
} else {
// eslint-disable-next-line react-hooks/rules-of-hooks
const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage);
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (activeLng === i18n.resolvedLanguage) return;
setActiveLng(i18n.resolvedLanguage);
}, [activeLng, i18n.resolvedLanguage]);
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (!lng || i18n.resolvedLanguage === lng) return;
i18n.changeLanguage(lng);
}, [lng, i18n]);
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (cookies.i18next === lng) return;
setCookie(cookieName, lng, { path: '/' });
}, [lng, cookies.i18next]);
}
return ret;
}middleware.ts
import { NextRequest, NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import { fallbackLng, languages, cookieName } from './settings'
acceptLanguage.languages(languages)
export const config = {
// matcher: '/:lng*'
matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)']
}
export async function middleware(req: NextRequest) {
if (req.nextUrl.pathname.startsWith('/_next')) return NextResponse.next()
let lng
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName)?.value)
if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language'))
if (!lng) lng = fallbackLng
if (!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`))) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url))
}
const lngInReferer = languages.find((l) => req.nextUrl.pathname.startsWith(`/${l}`))
const response = NextResponse.next()
if (lngInReferer) response.cookies.set(cookieName, lngInReferer)
return response
}应用 middleware
在根目录下创建
middleware.ts 文件,并添加以下内容:export * from './app/lib/i18n/middleware'settings.ts
export const fallbackLng = 'en'
export const languages = [fallbackLng, 'zh-CN']
export const defaultNS = 'translation'
export const cookieName = 'i18next'
export function getOptions(lng = fallbackLng, ns = defaultNS) {
return {
// debug: true,
supportedLngs: languages,
fallbackLng,
lng,
fallbackNS: defaultNS,
defaultNS,
ns
}
}
export const languageOptions = {
'en': 'English',
'zh-CN': '简体中文'
}4. 创建 app 目录及页面
我们假设项目中存在两个页面,分别是
index 和 about,目录结构如下:app/[lng]/layout.tsx
app/[lng]/page.tsx
app/[lng]/about/page.tsxapp/[lng]/layout.tsx
import { dir } from 'i18next'
import { languages } from '../lib/i18n/settings'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export default function RootLayout({
children,
params: {
lng
}
}: Readonly<{
children: React.ReactNode;
params: {
lng: string
}
}>) {
return (
<html lang={lng} dir={dir(lng)}>
<body>
{children}
</body>
</html>
);
}app/[lng]/page.tsx
import { useTranslation } from '../lib/i18n/server'
export default async function Page({ params: { lng } }: { params: { lng: string } }) {
const { t } = await useTranslation(lng, 'common')
return <div>{t('hello')}</div>
}app/[lng]/about/page.tsx
我们假设
about 页面需要使用客户端渲染,因此我们创建 components/aboutClient.tsx 文件,并在其中使用 useTranslation 函数。import AboutClient from './components/aboutClient'
export default function About({ params: { lng } }: { params: { lng: string } }) {
return <AboutClient lng={lng} />
}components/aboutClient.tsx
import { useTranslation } from '../lib/i18n/client'
export default function AboutClient({ lng }: { lng: string }) {
const { t } = useTranslation(lng, 'common')
return <div>{t('hello')}</div>
}至此,我们已经完成了在 Next.js App Route 中使用 i18n 的配置和实现。
5. 语言选择器
可以参考我这个组件
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { useTranslation } from '@/app/lib/i18n/client';
import { languageOptions } from '@/app/lib/i18n/settings';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"; // Shadcn Ui 库,可替换为其他 UI 库
const LanguageSwitcher: React.FC<{ lng: string }> = ({ lng }) => {
const router = useRouter();
const { t } = useTranslation(lng, 'common');
const changeLanguage = (languageCode: string) => {
router.push(`/${languageCode}`);
};
return (
<Select value={lng} onValueChange={changeLanguage}>
<SelectTrigger aria-label={t('selectLanguage')}>
<SelectValue>{t(languageOptions[lng as keyof typeof languageOptions])}</SelectValue>
</SelectTrigger>
<SelectContent>
{Object.entries(languageOptions).map(([lang, label]) => (
<SelectItem key={lang} value={lang}>
{t(label)}
</SelectItem>
))}
</SelectContent>
</Select>
);
};
export default LanguageSwitcher;6. 默认语言去除 Url 中的语言
创建一个
NormalizePath 组件,在 app/[lng]/layout.tsx 中引入并使用。'use client';
import React, { useEffect } from 'react';
import { usePathname } from 'next/navigation';
import { fallbackLng } from '@/app/lib/i18n/settings';
function NormalizePath() {
const pathname = usePathname();
useEffect(() => {
const pattern = new RegExp(`^\\\\/${fallbackLng}(\\\\/.*)?`);
window.history.pushState(
null,
'',
pathname.replace(pattern, (_, group) => (group ? group : '/')),
);
}, [pathname]);
return <div></div>;
}
export default NormalizePath;在
app/[lng]/layout.tsx 中引入并使用。import { dir } from 'i18next'
import { languages } from '../lib/i18n/settings'
import NormalizePath from './components/NormalizePath'
export async function generateStaticParams() {
return languages.map((lng) => ({ lng }))
}
export default function RootLayout({
children,
params: {
lng
}
}: Readonly<{
children: React.ReactNode;
params: {
lng: string
}
}>) {
return (
<html lang={lng} dir={dir(lng)}>
<body>
<NormalizePath />
{children}
</body>
</html>
);
}碎碎念
本次使用 Next.js App Route 实现 i18n 也学到了很多东西,如果有需要可以邮箱联系我交流~