最近开发的系统中需要支持多语言,因此调研了一下 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 也学到了很多东西,如果有需要可以邮箱联系我交流~