JackyLove 的技术人生

人生艰难,唯有一技傍身才能慢慢走向通途。个人博客,记录生活,分享技术,记录成长。专注全栈技术,next技术。

第55章—实战篇-博客-i18n

首次发表于 2024-07-29, 更新于 2024-07-29

前言

《路由篇 | 国际化》我们讲解了国际化的基本原理,但在实际开发中,我们往往会使用 react-i18next 或者 next-intl 辅助我们开发。在《实战篇 | React Notes | 国际化》中,我们分别对这两种技术选型进行了讲解。

其中, react-i18next 自由度高,但配置相对繁琐。next-intl 自由度低,但配置简单省事。本篇我们使用 react-i18next 来实现国际化。

我们想要实现的效果如下:

7.gif

页面右上角添加一个语言切换器,有中文和英文两种语言,默认是中文。以博客列表页面地址为例,中文页面地址是 http://localhost:3000/posts,英文页面地址为 http://localhost:3000/en/posts。当点击切换语言的时候,页面无刷新,直接实现切换。

react-i18next

1. 新建文件夹

app 目录下添加一个名为 [lng] 的文件夹,将 favicon.ico 以外的文件,移动到该文件夹下:

image.png

因为加了一层动态路由,如果要访问原本的博客列表页面,需要访问 http://localhost:3000/xxx/posts,比如:

image.png

因为文章标题的页面链接地址还是之前的,所以点击链接跳转会出现错误。我们修改下 app/[lng]/posts/page.js,修改代码如下:

// ...

function PostCard({ lng, ...post }) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link
          href={`/${lng}${post.url}`}
          className="text-blue-700 hover:text-blue-900 dark:text-blue-400"
        >
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {dayjs(post.date).format("DD/MM/YYYY")}
      </time>
    </div>
  );
}

export default function Home({ params: { lng } }) {
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">My Blog List</h1>
      {allPosts.map((post, idx) => (
        <PostCard key={idx} lng={lng} {...post} />
      ))}
    </div>
  );
}

此时页面正常跳转:

8.gif

2. 添加全局 i18n 配置

修改 data/siteMetadata.js,添加 language 配置项:

const siteMetadata = {
  siteUrl: 'https://yayujs.com',
  title: '冴羽的技术博客',
  description: '冴羽的技术博客,分享技术、个人成长等内容',
  author: '冴羽',
  socialBanner: 'https://cdna.artstation.com/p/assets/images/images/028/138/058/large/z-w-gu-bandageb5f.jpg?1593594749',
  languages: ['zh', 'en'],
  fallbackLanguage: "zh"
}

export default siteMetadata

我们新建了 2 个字段,languages 用于指定 i18n 支持的语言,并据此生成语言切换器。fallbackLanguage 用于指定默认语言。

安装依赖项 i18next:

npm i i18next

修改 app/[lng]/layout.js,代码如下:

// ...
import { dir } from 'i18next'

// 添加静态路由
export async function generateStaticParams() {
  return siteMetadata.languages.map((lng) => ({ lng }))
}

// 为 html 元素添加 lang、dir 属性
export default function RootLayout({ children, params: { lng } }) {
  return (
    <html lang={lng} dir={dir(lng)} suppressHydrationWarning>
      <body>
        <ThemeProviders>
          <header className="flex justify-end">
            <ThemeSwitch />
            <LangSwitch />
          </header>
          {children}
        </ThemeProviders>
      </body>
    </html>
  );
}

3. 中间件设置

安装依赖项 accept-language

npm i accept-language

这是一个帮助我们匹配语言的库。它的基本用法如下:

import acceptLanguage from 'accept-language';
acceptLanguage.languages(['en-US', 'zh-CN']);
console.log(acceptLanguage.get('en-GB,en;q=0.8,sv'));
/* 'en-US' */

在这段代码中,['en-US', 'zh-CN']表示我们支持的语言,'en-GB,en;q=0.8,sv'表示 HTTP 的 Accept-Language 标头。调用 get 方法会从支持语言中匹配出合适的语言。

项目根目录新建 middleware.js,代码如下:

import { NextResponse } from 'next/server'
import acceptLanguage from 'accept-language'
import siteMetadata from './data/siteMetadata'

const { fallbackLanguage, languages } = siteMetadata
acceptLanguage.languages(languages)

const publicFile = /\.(.*)$/
const excludeFile = []

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|assets|favicon.ico|sw.js|site.webmanifest).*)']
}

function getLocale(req) { 
  let language = acceptLanguage.get(req.headers.get('Accept-Language'))
  if (!language) language = fallbackLanguage
  return language
 }
 
export function middleware(request) {
  const { pathname } = request.nextUrl

  // 判断路径中是否存在支持的语言
  const filtedLanguage = languages.filter((locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`)

  if (filtedLanguage.length > 0) {
    if (filtedLanguage[0] === fallbackLanguage) {
      // /zh/xxx 重定向到 `/xxx`
      const url = pathname.replace(`/${fallbackLanguage}`, '');
      return NextResponse.redirect(new URL(url ? url : '/', request.url))
    }
    // 其他跳过
    return
  }

  // 如果是 public 文件,不重定向
  if (publicFile.test(pathname) && excludeFile.indexOf(pathname.substr(1)) == -1) return
 
  // 获取匹配的 locale
  const locale = getLocale(request)
  request.nextUrl.pathname = `/${locale}${pathname}`

  // 默认语言不重定向
  if (locale == fallbackLanguage) {
    return NextResponse.rewrite(request.nextUrl)
  }
  // 重定向,如 /products 重定向到 /en/products
  return Response.redirect(request.nextUrl)
}

这里我们自定义了一些逻辑,访问 http://localhost:3000/zh会重定向到 http://localhost:3000/,访问 http://localhost:3000因为重写到 http://localhost:3000/zh,所以可以正常访问,不会出现 404 错误。具体效果如下:

9.gif

4. 配置翻译文件

路由的配置已经完成,接下来配置翻译相关的文件。

安装依赖项:

npm i i18next i18next-resources-to-backend react-i18next

新建 next-blog/app/i18n/index.js,代码如下:

import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next/initReactI18next'
import siteMetadata from '@/data/siteMetadata'

const { fallbackLanguage, languages } = siteMetadata

const initI18next = async (lng = fallbackLanguage, ns = 'basic') => {
  const i18nInstance = createInstance()
  await i18nInstance
    .use(initReactI18next)
    .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
    .init({
      // debug: true,
      supportedLngs: languages,
      fallbackLng: fallbackLanguage,
      lng,
      fallbackNS: 'basic',
      defaultNS: 'basic',
      ns
    })
  return i18nInstance
}

export async function useTranslation(lng, ns, options = {}) {
  const i18nextInstance = await initI18next(lng, ns)
  return {
    t: i18nextInstance.getFixedT(lng, Array.isArray(ns) ? ns[0] : ns, options.keyPrefix),
    i18n: i18nextInstance
  }
}

准备翻译文件:

app                      
└─ i18n                  
   ├─ locales            
   │  ├─ en              
   │  │  └─ basic.json  
   │  └─ zh              
   │     └─ basic.json        
   └─ index.js

zh/basic.json代码如下:

{
  "blogList": "我的博客列表",
  "like": "喜欢"
}

en/basic.json代码如下:

{
  "blogList": "My Blog List",
  "like": "like"
}

为了方便引入 useTranslation,修改 jsconfig.json,添加代码如下:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/data/*": ["data/*"],
      "@/components/*": ["components/*"],
      "@/*": ["/*"],
      "contentlayer/generated": ["./.contentlayer/generated"]
    }
  },
  "include": [
    "next-env.d.js",
    "**/*.js",
    "**/*.jsx",
    ".next/types/**/*.js",
    ".contentlayer/generated"
  ]
}

5. 服务端组件使用翻译

修改 app/[lng]/posts/page.js,代码如下:

import Link from 'next/link'
import { allPosts } from 'contentlayer/generated'
import dayjs from "dayjs";
import { useTranslation } from "@/app/i18n/index.js"
import Like from './like';

export const generateMetadata = ({ params }) => {
  return { 
    title: "博客列表",
    description: "这是博客列表页面",
    openGraph: {
      title: '博客列表',
      description: '这是博客列表页面'
    }
  }
}

function PostCard({lng, ...post}) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link href={`/${lng}${post.url}`} className="text-blue-700 hover:text-blue-900 dark:text-blue-400">
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {dayjs(post.date).format('DD/MM/YYYY')}
      </time>
    </div>
  )
}

export default async function Home({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">{t('blogList')}</h1>
      {allPosts.map((post, idx) => (
        <PostCard key={idx} {...post} lng={lng} />
      ))}
    </div>
  )
}

此时博客列表页面已经成功渲染,同时能根据路由切换翻译:

10.gif

6. 客户端组件使用翻译

刚才的使用方式适合服务端组件,尽可能使用服务端组件的方式。但如果需要在客户端组件中使用呢?

安装用到的库:

npm i react-cookie i18next-browser-languagedetector

新建 app/i18n/client.js ,代码如下:

'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 siteMetadata from '@/data/siteMetadata'

const { fallbackLanguage: defaultLocale, languages: locales } = siteMetadata
export const cookieName = 'i18next'

const runsOnServerSide = typeof window === 'undefined'

i18next
  .use(initReactI18next)
  .use(LanguageDetector)
  .use(resourcesToBackend((language, namespace) => import(`./locales/${language}/${namespace}.json`)))
  .init({
    supportedLngs: locales,
    fallbackLng: defaultLocale,
    lng: defaultLocale,
    fallbackNS: 'basic',
    defaultNS: 'basic',
    ns: 'basic',
    lng: undefined,
    detection: {
      order: ['path', 'htmlTag', 'cookie', 'navigator'],
    },
    preload: runsOnServerSide ? locales : []
  })

export function useTranslation(lng, ns, options) {
  const [cookies, setCookie] = useCookies([cookieName])
  const ret = useTranslationOrg(ns, options)
  const { i18n } = ret
  if (runsOnServerSide && lng && i18n.resolvedLanguage !== lng) {
    i18n.changeLanguage(lng)
  } else {
    const [activeLng, setActiveLng] = useState(i18n.resolvedLanguage)
    useEffect(() => {
      if (activeLng === i18n.resolvedLanguage) return
      setActiveLng(i18n.resolvedLanguage)
    }, [activeLng, i18n.resolvedLanguage])
    useEffect(() => {
      if (!lng || i18n.resolvedLanguage === lng) return
      i18n.changeLanguage(lng)
    }, [lng, i18n])
    useEffect(() => {
      if (cookies.i18next === lng) return
      setCookie(cookieName, lng, { path: '/' })
    }, [lng, cookies.i18next])
  }
  return ret
}

新建 app/[lng]/posts/like.js,代码如下:

'use client';

import { useTranslation } from "@/app/i18n/client.js"
export default function Like({lng}) {
  const { t } = useTranslation(lng, 'basic')
  return <button>{t('like')}</button>
}

修改 app/[lng]/posts/page.js,代码如下:

import Link from 'next/link'
import { allPosts } from 'contentlayer/generated'
import dayjs from "dayjs";
import { useTranslation } from "@/app/i18n/index.js"
import Like from './like';

export const generateMetadata = ({ params }) => {
  return {
    title: "博客列表",
    description: "这是博客列表页面",
    openGraph: {
      title: '博客列表',
      description: '这是博客列表页面'
    }
  }
}

function PostCard({ lng, ...post }) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link href={`/${lng}${post.url}`} className="text-blue-700 hover:text-blue-900 dark:text-blue-400">
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {dayjs(post.date).format('DD/MM/YYYY')}
      </time>
      <Like lng={lng} />
    </div>
  )
}

export default async function Home({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">{t('blogList')}</h1>
      {allPosts.map((post, idx) => (
        <PostCard key={idx} {...post} lng={lng} />
      ))}
    </div>
  )
}

我们添加了一个点赞按钮用于客户端组件的示例(尽管我们并没有添加任何事件),此时效果如下:

11.gif

不同语言下的使用也没有问题。

7. 添加语言切换器

新建 components/LangSwitch.js,代码如下:

"use client";

import { useState, useRef, Fragment, useEffect } from "react";
import {
  usePathname,
  useParams,
  useRouter,
  useSelectedLayoutSegments,
} from "next/navigation";
import siteMetadata from "@/data/siteMetadata";
import { Menu, Transition, RadioGroup } from "@headlessui/react";

const { languages } = siteMetadata;

const LangSwitch = () => {
  const urlSegments = useSelectedLayoutSegments();
  const router = useRouter();
  const params = useParams();
  const [locale, setLocal] = useState(params?.lng);


  const handleLocaleChange = (newLocale) => {
    const newUrl = `/${newLocale}/${urlSegments.join("/")}`;
    return newUrl;
  };

  const handleLinkClick = (newLocale) => {
    const resolvedUrl = handleLocaleChange(newLocale);
    router.push(resolvedUrl);
  };

  return (
    <div className="relative inline-block text-left mr-5">
      <Menu>
        <div>
          <Menu.Button>
            {locale.charAt(0).toUpperCase() + locale.slice(1)}
          </Menu.Button>
        </div>
        <Transition
          as={Fragment}
          enter="transition-all ease-out duration-300"
          enterFrom="opacity-0 scale-95 translate-y-[-10px]"
          enterTo="opacity-100 scale-100 translate-y-0"
          leave="transition-all ease-in duration-200"
          leaveFrom="opacity-100 scale-100 translate-y-0"
          leaveTo="opacity-0 scale-95 translate-y-[10px]"
          >
          <Menu.Items className="absolute right-0 z-50 mt-2 w-12 origin-top-right divide-y divide-gray-100 rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:bg-gray-800">
            <RadioGroup value={locale} onChange={handleLinkClick}>
              <div className="py-1">
                {languages.map((newLocale) => (
            <RadioGroup.Option key={newLocale} value={newLocale}>
              <Menu.Item>
                <button className="group flex w-full items-center rounded-md px-2 py-2 text-sm">
                  {newLocale.charAt(0).toUpperCase() + newLocale.slice(1)}
                </button>
              </Menu.Item>
            </RadioGroup.Option>
          ))}
              </div>
            </RadioGroup>
          </Menu.Items>
        </Transition>
      </Menu>
    </div>
  );
};

export default LangSwitch;

修改 app/[lng]/layout.js,引入 LangSwitch 组件,代码如下:

// ...
import LangSwitch from '@/components/LangSwitch';

// ...

export default function RootLayout({ children, params: { lng } }) {
  return (
    <html lang={lng} dir={dir(lng)} suppressHydrationWarning>
      <body>
        <ThemeProviders>
          <header className="flex justify-end">
            <ThemeSwitch />
            <LangSwitch />
          </header>
          {children}
        </ThemeProviders>
      </body>
    </html>
  );
}

此时效果如下:

12.gif

8. Metadata 如何生成?

metadata 如何根据国际化生成呢?其实用法跟服务端组件一样。

我们为博客列表页面添加一个单独的翻译文件。新建 app/i18n/locales/en/posts.json,代码如下:

{
  "title": "My Blog List",
  "description": "This is My blog list description"
}

新建 app/i18n/locales/zh/posts.json,代码如下:

{
  "title": "我的博客列表",
  "description": "这是我的博客列表页面的描述"
}

修改 app/[lng]/posts/page.js,完整代码如下:

import Link from 'next/link'
import { allPosts } from 'contentlayer/generated'
import dayjs from "dayjs";
import { useTranslation } from "@/app/i18n/index.js"
import Like from './like';

export const generateMetadata = async ({ params: { lng } }) => {
  const { t } = await useTranslation(lng, 'posts')
  return {
    title: t("title"),
    description: t("description"),
    openGraph: {
      title: '博客列表',
      description: '这是博客列表页面'
    }
  }
}

function PostCard({ lng, ...post }) {
  return (
    <div className="mb-8">
      <h2 className="mb-1 text-xl">
        <Link href={`/${lng}${post.url}`} className="text-blue-700 hover:text-blue-900 dark:text-blue-400">
          {post.title}
        </Link>
      </h2>
      <time dateTime={post.date} className="mb-2 block text-xs text-gray-600">
        {dayjs(post.date).format('DD/MM/YYYY')}
      </time>
      <Like lng={lng} />
    </div>
  )
}

export default async function Home({ params: { lng } }) {
  const { t } = await useTranslation(lng)
  return (
    <div className="mx-auto max-w-xl py-8">
      <h1 className="mb-8 text-center text-2xl font-black">{t('blogList')}</h1>
      {allPosts.map((post, idx) => (
        <PostCard key={idx} {...post} lng={lng} />
      ))}
    </div>
  )
}

此时效果如下:

13.gif

可以看到,页面的元数据也随之发生了改变。

项目源码

  1. 功能实现:博客支持国际化
  2. 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-blog-4
  3. 下载代码:git clone -b next-blog-4 git@github.com:mqyqingfeng/next-app-demo.git

总结

其实本篇跟《实战篇 | React Notes | 国际化》中的实现代码非常类似。使用 react-i18next 虽然配置繁琐,但自由度更高,可以根据自己的需求自定义效果。

© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru