最近开发的系统中需要支持多语言,因此调研了一下 Next.js 的 App Route 中如何实现 i18n。
配置
1. 安装依赖
1pnpm install i18next react-i18next i18next-resources-to-backend
2. 创建翻译文件
我们假设项目中需要支持的语言有
en
和 zh
,那么首先在 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 目录及页面
我们假设项目中存在两个页面,分别是
index
和 about
,目录结构如下: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 也学到了很多东西,如果有需要可以邮箱联系我交流~