在 Next.js App Route 中使用 i18n

最近开发的系统中需要支持多语言,因此调研了一下 Next.js 的 App Route 中如何实现 i18n。

配置

1. 安装依赖

pnpm install i18next react-i18next i18next-resources-to-backend

2. 创建翻译文件

我们假设项目中需要支持的语言有

en
zh
,那么首先在
public/locales/[language]
目录下创建
/common.json

public/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.tsx

app/[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 也学到了很多东西,如果有需要可以邮箱联系我交流~