ACQUIRING IMAGE…

LOG ENTRYCAT / 教程

在 Next.js App Route 中使用 i18n

详细介绍在 Next.js App Router 中实现 i18n 国际化的完整方案,包括中间件配置和语言切换组件。

前端开发经验分享

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

配置

1. 安装依赖

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

2. 创建翻译文件

我们假设项目中需要支持的语言有 enzh,那么首先在 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 目录及页面

我们假设项目中存在两个页面,分别是 indexabout,目录结构如下:
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 也学到了很多东西,如果有需要可以邮箱联系我交流~