在《路由篇 | 国际化》我们讲解了国际化的基本原理,但在实际开发中,我们往往会使用 react-i18next 或者 next-intl 辅助我们开发。在《实战篇 | React Notes | 国际化》中,我们分别对这两种技术选型进行了讲解。
其中, react-i18next 自由度高,但配置相对繁琐。next-intl 自由度低,但配置简单省事。本篇我们使用 react-i18next 来实现国际化。
我们想要实现的效果如下:
页面右上角添加一个语言切换器,有中文和英文两种语言,默认是中文。以博客列表页面地址为例,中文页面地址是 http://localhost:3000/posts
,英文页面地址为 http://localhost:3000/en/posts
。当点击切换语言的时候,页面无刷新,直接实现切换。
在 app
目录下添加一个名为 [lng]
的文件夹,将 favicon.ico
以外的文件,移动到该文件夹下:
因为加了一层动态路由,如果要访问原本的博客列表页面,需要访问 http://localhost:3000/xxx/posts,比如:
因为文章标题的页面链接地址还是之前的,所以点击链接跳转会出现错误。我们修改下 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>
);
}
此时页面正常跳转:
修改 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>
);
}
安装依赖项 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 错误。具体效果如下:
路由的配置已经完成,接下来配置翻译相关的文件。
安装依赖项:
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"
]
}
修改 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>
)
}
此时博客列表页面已经成功渲染,同时能根据路由切换翻译:
刚才的使用方式适合服务端组件,尽可能使用服务端组件的方式。但如果需要在客户端组件中使用呢?
安装用到的库:
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>
)
}
我们添加了一个点赞按钮用于客户端组件的示例(尽管我们并没有添加任何事件),此时效果如下:
不同语言下的使用也没有问题。
新建 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>
);
}
此时效果如下:
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>
)
}
此时效果如下:
可以看到,页面的元数据也随之发生了改变。
- 功能实现:博客支持国际化
- 源码地址:https://github.com/mqyqingfeng/next-app-demo/tree/next-blog-4
- 下载代码:
git clone -b next-blog-4 git@github.com:mqyqingfeng/next-app-demo.git
其实本篇跟《实战篇 | React Notes | 国际化》中的实现代码非常类似。使用 react-i18next 虽然配置繁琐,但自由度更高,可以根据自己的需求自定义效果。