在 Next.js App Route 中使用 i18n

分类
教程
标签
前端开发经验分享

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

配置

1. 安装依赖

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

2. 创建翻译文件

我们假设项目中需要支持的语言有 enzh,那么首先在 public/locales/[language] 目录下创建 /common.json
1public/locales/en/common.json 2public/locales/zh/common.json
然后在 public/locales/en/common.json 中添加需要支持的语言的翻译内容,例如:
1{ 2 "hello": "Hello" 3}
public/locales/zh/common.json 中添加需要支持的语言的翻译内容,例如:
1{ 2 "hello": "你好" 3}

3. 创建 i18n 配置

我们在 app 目录下创建 lib/i18n 目录,并在其中创建以下几个文件:
1app/lib/i18n/server.ts 2app/lib/i18n/client.ts 3app/lib/i18n/middleware.ts 4app/lib/i18n/settings.ts
其内容如下:

server.ts

1import { createInstance } from 'i18next' 2import resourcesToBackend from 'i18next-resources-to-backend' 3import { initReactI18next } from 'react-i18next/initReactI18next' 4import { getOptions } from './settings' 5import { i18n } from 'i18next' 6 7const initI18next = async (lng: string, ns: string): Promise<i18n> => { 8 const i18nInstance = createInstance() 9 await i18nInstance 10 .use(initReactI18next) 11 .use(resourcesToBackend((language: string, namespace: string) => 12 import(`../../../public/locales/${language}/${namespace}.json`) 13 )) 14 .init(getOptions(lng, ns)) 15 return i18nInstance 16} 17 18export async function useTranslation( 19 lng: string, 20 ns: string, 21 options: { keyPrefix?: string } = {} 22) { 23 const i18nextInstance = await initI18next(lng, ns) 24 return { 25 t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix), 26 i18n: i18nextInstance 27 } 28}

client.ts

1'use client'; 2 3import { useEffect, useState } from 'react'; 4import i18next from 'i18next'; 5import { initReactI18next, useTranslation as useTranslationOrg } from 'react-i18next'; 6import { useCookies } from 'react-cookie'; 7import resourcesToBackend from 'i18next-resources-to-backend'; 8import LanguageDetector from 'i18next-browser-languagedetector'; 9import { getOptions, languages, cookieName } from './settings'; 10 11const runsOnServerSide = typeof window === 'undefined'; 12 13// 14i18next 15 .use(initReactI18next) 16 .use(LanguageDetector) 17 .use(resourcesToBackend((language: string, namespace: string) => import(`../../../public/locales/${language}/${namespace}.json`))) 18 .init({ 19 ...getOptions(), 20 lng: undefined, // let detect the language on client side 21 detection: { 22 order: ['path', 'htmlTag', 'cookie', 'navigator'], 23 }, 24 preload: runsOnServerSide ? languages : [], 25 }); 26 27export function useTranslation(lng: string, ns: string, options: { keyPrefix?: string } = {}) { 28 const [cookies, setCookie] = useCookies([cookieName]); 29 const ret = useTranslationOrg(ns, options); 30 const { i18n } = ret; 31 if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) { 32 i18n.changeLanguage(lng); 33 } else { 34 // eslint-disable-next-line react-hooks/rules-of-hooks 35 const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage); 36 // eslint-disable-next-line react-hooks/rules-of-hooks 37 useEffect(() => { 38 if (activeLng === i18n.resolvedLanguage) return; 39 setActiveLng(i18n.resolvedLanguage); 40 }, [activeLng, i18n.resolvedLanguage]); 41 // eslint-disable-next-line react-hooks/rules-of-hooks 42 useEffect(() => { 43 if (!lng || i18n.resolvedLanguage === lng) return; 44 i18n.changeLanguage(lng); 45 }, [lng, i18n]); 46 // eslint-disable-next-line react-hooks/rules-of-hooks 47 useEffect(() => { 48 if (cookies.i18next === lng) return; 49 setCookie(cookieName, lng, { path: '/' }); 50 }, [lng, cookies.i18next]); 51 } 52 return ret; 53}

middleware.ts

1import { NextRequest, NextResponse } from 'next/server' 2import acceptLanguage from 'accept-language' 3import { fallbackLng, languages, cookieName } from './settings' 4 5acceptLanguage.languages(languages) 6 7export const config = { 8 // matcher: '/:lng*' 9 matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js).*)'] 10} 11 12export async function middleware(req: NextRequest) { 13 if (req.nextUrl.pathname.startsWith('/_next')) return NextResponse.next() 14 let lng 15 if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName)?.value) 16 if (!lng) lng = acceptLanguage.get(req.headers.get('Accept-Language')) 17 if (!lng) lng = fallbackLng 18 19 if (!languages.some(loc => req.nextUrl.pathname.startsWith(`/${loc}`))) { 20 return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}`, req.url)) 21 } 22 23 const lngInReferer = languages.find((l) => req.nextUrl.pathname.startsWith(`/${l}`)) 24 const response = NextResponse.next() 25 if (lngInReferer) response.cookies.set(cookieName, lngInReferer) 26 return response 27}

应用 middleware

在根目录下创建 middleware.ts 文件,并添加以下内容:
1export * from './app/lib/i18n/middleware'

settings.ts

1export const fallbackLng = 'en' 2export const languages = [fallbackLng, 'zh-CN'] 3export const defaultNS = 'translation' 4export const cookieName = 'i18next' 5 6export function getOptions(lng = fallbackLng, ns = defaultNS) { 7 return { 8 // debug: true, 9 supportedLngs: languages, 10 fallbackLng, 11 lng, 12 fallbackNS: defaultNS, 13 defaultNS, 14 ns 15 } 16} 17 18export const languageOptions = { 19 'en': 'English', 20 'zh-CN': '简体中文' 21}

4. 创建 app 目录及页面

我们假设项目中存在两个页面,分别是 indexabout,目录结构如下:
1app/[lng]/layout.tsx 2app/[lng]/page.tsx 3app/[lng]/about/page.tsx

app/[lng]/layout.tsx

1import { dir } from 'i18next' 2import { languages } from '../lib/i18n/settings' 3 4export async function generateStaticParams() { 5 return languages.map((lng) => ({ lng })) 6} 7 8export default function RootLayout({ 9 children, 10 params: { 11 lng 12 } 13}: Readonly<{ 14 children: React.ReactNode; 15 params: { 16 lng: string 17 } 18}>) { 19 return ( 20 <html lang={lng} dir={dir(lng)}> 21 <body> 22 {children} 23 </body> 24 </html> 25 ); 26}

app/[lng]/page.tsx

1import { useTranslation } from '../lib/i18n/server' 2 3export default async function Page({ params: { lng } }: { params: { lng: string } }) { 4 const { t } = await useTranslation(lng, 'common') 5 return <div>{t('hello')}</div> 6}

app/[lng]/about/page.tsx

我们假设 about 页面需要使用客户端渲染,因此我们创建 components/aboutClient.tsx 文件,并在其中使用 useTranslation 函数。
1import AboutClient from './components/aboutClient' 2 3export default function About({ params: { lng } }: { params: { lng: string } }) { 4 return <AboutClient lng={lng} /> 5}

components/aboutClient.tsx

1import { useTranslation } from '../lib/i18n/client' 2 3export default function AboutClient({ lng }: { lng: string }) { 4 const { t } = useTranslation(lng, 'common') 5 return <div>{t('hello')}</div> 6}
至此,我们已经完成了在 Next.js App Route 中使用 i18n 的配置和实现。

5. 语言选择器

可以参考我这个组件
1'use client'; 2 3import React from 'react'; 4import { useRouter } from 'next/navigation'; 5import { useTranslation } from '@/app/lib/i18n/client'; 6import { languageOptions } from '@/app/lib/i18n/settings'; 7import { 8 Select, 9 SelectContent, 10 SelectItem, 11 SelectTrigger, 12 SelectValue, 13} from "@/components/ui/select"; // Shadcn Ui 库,可替换为其他 UI 库 14 15const LanguageSwitcher: React.FC<{ lng: string }> = ({ lng }) => { 16 const router = useRouter(); 17 const { t } = useTranslation(lng, 'common'); 18 19 const changeLanguage = (languageCode: string) => { 20 router.push(`/${languageCode}`); 21 }; 22 23 return ( 24 <Select value={lng} onValueChange={changeLanguage}> 25 <SelectTrigger aria-label={t('selectLanguage')}> 26 <SelectValue>{t(languageOptions[lng as keyof typeof languageOptions])}</SelectValue> 27 </SelectTrigger> 28 <SelectContent> 29 {Object.entries(languageOptions).map(([lang, label]) => ( 30 <SelectItem key={lang} value={lang}> 31 {t(label)} 32 </SelectItem> 33 ))} 34 </SelectContent> 35 </Select> 36 ); 37}; 38 39export default LanguageSwitcher;

6. 默认语言去除 Url 中的语言

创建一个 NormalizePath 组件,在 app/[lng]/layout.tsx 中引入并使用。
1'use client'; 2 3import React, { useEffect } from 'react'; 4import { usePathname } from 'next/navigation'; 5import { fallbackLng } from '@/app/lib/i18n/settings'; 6 7function NormalizePath() { 8 const pathname = usePathname(); 9 useEffect(() => { 10 const pattern = new RegExp(`^\\\\/${fallbackLng}(\\\\/.*)?`); 11 window.history.pushState( 12 null, 13 '', 14 pathname.replace(pattern, (_, group) => (group ? group : '/')), 15 ); 16 }, [pathname]); 17 return <div></div>; 18} 19 20export default NormalizePath;
app/[lng]/layout.tsx 中引入并使用。
1import { dir } from 'i18next' 2import { languages } from '../lib/i18n/settings' 3import NormalizePath from './components/NormalizePath' 4export async function generateStaticParams() { 5 return languages.map((lng) => ({ lng })) 6} 7 8export default function RootLayout({ 9 children, 10 params: { 11 lng 12 } 13}: Readonly<{ 14 children: React.ReactNode; 15 params: { 16 lng: string 17 } 18}>) { 19 return ( 20 <html lang={lng} dir={dir(lng)}> 21 <body> 22 <NormalizePath /> 23 {children} 24 </body> 25 </html> 26 ); 27}

碎碎念

本次使用 Next.js App Route 实现 i18n 也学到了很多东西,如果有需要可以邮箱联系我交流~