Nextjs 实现国际化翻译 - App Router 模式解决方案
老板:最近我们网站想部署到国外去啊,我们加一个国际化翻译的功能,O 不 OK?
我:接一下谷歌翻译嘛,啪的一下,很快嗷!
老板 :忘了说了,这个翻译最好能自己配置、没有配置文件的话需要兜底、变量插值,如果可以的话,页面的
title
也要能作用到哈,我们最近要搞SEO
的。我:???
前言
如果拒绝的话,明天大家就看不到我了(不是),所以本篇文章我们从零开始构建一个 Next
+ App Router
的国际化翻译方案,其中将涉及到语言切换、翻译文件管理等要点。国际化翻译对于一个门户网站来说,是一个至关重要的功能,不管是为了你的网站有更多的 SEO
权重,还是为你的海外用户提供更好的用户体验,都是一个很值得做的优化。本文中所使用的代码已上传至 Github 仓库,这里是 Demo 访问地址。
⚠️ 本文更偏向于介绍在国际化翻译过程中,处理问题的思路与优化方向,所以我们在这里不使用
next-intl
等国际化翻译库,有兴趣了解的同学可以点这里:Next Intl。
起步
首先我们创建一个新的 Next
项目,创建的过程中全选 Yes
即可,请注意这里的 Next
版本需要超过 13
,最好需要使用 v13.4
之后的稳定版本,我这里的版本是 13.4.12
,node 版本为 20.13.1
shell
npx create-next-app@latest my-i18n-app
cd my-i18n-app
yarn dev
现在我们已经有了一个基础的 Next
项目,并且可以运行,目前大概的文件结构为:
lua
/.next
/node_modules
/src
/app
/fonts
favicon.ico
globals.css
layout.tsx
page.tsx
.eslintrc.json
.gitignore
next-env.d.ts
next.config.mjs
package-lock.json
package.json
postcss.config.ts
README.md
tailwind.config.ts
tsconfig.json
执行 yarn dev
完毕之后成功运行了,这个时候你应该可以看到以下内容:
接下来我们安装我们本次需要用到的库,执行 yarn add server-only
,server-only
库的作用是确保该模块仅在服务器端执行。安装完毕之后,我们的 package.json
文件预计应该是如下内容:
json
{
"name": "my-i18n-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.15",
"react": "^18",
"react-dom": "^18",
"server-only": "^0.0.1"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.15",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
设置页面结构
我们先从搭建页面开始,这个页面我们用来承载国际化翻译的函数与使用,并且默认为服务端组件,如果需要转化为客户端组件的话,可以在组件的头部标注 'use client'
,Next 会自动将这个组件转为客户端组件。什么是客户端组件
我们创建一个 src/app/[lang]/page.tsx
作为主页,并使用 src/app/[lang]/layout.tsx
作为布局文件。[lang]
这种结构允许我们为每种语言创建独特的 URL
路径。
[lang]
这种写法叫做动态 API 路由,在NextJS
的App Router
和Page Router
模式下都可使用,允许根据URL
中的参数动态地生成页面内容,并且对SEO
友好,详情参见:动态 API 路由
tsx
// ./src/app/[lang]/page.tsx
import { Metadata } from 'next'
export default async function Home({
params: { lang },
}: {
params: { lang: string }
}) {
return (
<div>
<main>
<h1>
Welcome to Next.js I18N
</h1>
<p>
This is a Next.js app with server-side internationalization.
</p>
<div>
This language is: {lang}
</div>
</main>
</div>
)
}
export async function generateMetadata({
params: { lang },
}: {
params: { lang: string }
}): Promise<Metadata> {
return {
title: "Next.js I18N App " + lang,
description: "This is a Next.js app with server-side internationalization."
}
}
tsx
// ./src/app/[lang]/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Next.js I18N App',
description: 'A Next.js app with server-side internationalization',
}
export default function RootLayout({
children,
params,
}: {
children: React.ReactNode
params: { lang: string }
}) {
return (
<html lang={params.lang}>
<body className={inter.className}>{children}</body>
</html>
)
}
当大家访问 http://localhost:3000/en
的时候,就会发现可以看到页面上出现了语言标识 en
,我们后续就可以使用这个标识去作为请求、加载语言文件的依赖。
翻译文件准备
我们的目标,除了需要支持远程加载语言文件外,还需要当请求失败的时候,做翻译的兜底,所以我们在这个环节,暂时使用本地的翻译文件来跑通翻译功能 。我们先在 /src
文件夹下创建一个 /dictionaries
文件夹,并在 /dictionaries
文件夹下创建三个翻译 JSON
文件,分别为 zh.json
、en.json
、ja.json
,并在其中置入初始的翻译配置,这里为了方便展示,合并在一起写:
json
// ./src/dictionaries/zh.json
{
"title": "你好,Cooyue柯同学",
"welcome to {posi}, {name}": "欢迎来到 {posi}, {name}",
"description": "这是一个具有服务器端国际化功能的 Next.js 应用。"
}
// ./src/dictionaries/en.json
{
"title": "Hello Cooyue Ke",
"welcome to {posi}, {name}": "Welcome to {posi}, {name}",
"description": "This is a Next.js app with server-side internationalization."
}
// ./src/dictionaries/ja.json
{
"title": "こんにちは Cooyue Ke",
"welcome to {posi}, {name}": "{posi} へようこそ, {name}",
"description": "これはサーバーサイドの国際化機能を持つ Next.js アプリです。"
}
我们定义了中文、英文、日文三种翻译配置,那么我们提供一个配置文件,便于后续的使用和统一管理。我们在 /src
文件夹下新建一个 i18n-config.ts
文件:
ts
// ./src/i18n-config.ts
export const i18n = {
defaultLocale: 'zh', // 默认为中文
locales: ['zh', 'en', 'ja'], // 支持中文、英文、日文
} as const
export type Locale = (typeof i18n)['locales'][number]
默认语言
用户在访问我们的网站的时候,一般是不会显式地去输入某种语言标识的,作为一个使用者,我只希望我输入 https://yourdomain
之后,可以看到一个有默认语言的页面即可,所以我们需要一个中间件来做到这个事情。在 Next
中,存在一个默认的中间件配置 middleware.js
文件,通常位于项目的根文件夹或 /app
文件夹中。这个中间件的主要作用是做请求拦截、重定向、修改请求及响应,还可以集成鉴权等功能。详情可见:NextJS middleware,我们这边就需要使用到他的重定向等功能,我们先在 /src
文件夹下创建一个 middleware.js
文件:
ts
// ./src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { i18n } from './i18n-config'
// 获取用户语言偏好
function getLocale(request: NextRequest): string {
// 获取请求头中的 accept-language 字段
const acceptLanguage = request.headers.get('accept-language')
const languages = acceptLanguage ? acceptLanguage.split(',') : []
// 可用的语言列表
const locales: string[] = i18n.locales as unknown as string[]
// 开始匹配语言
for (const lang of languages) {
const language = lang.split(';')[0].trim().toLowerCase(); // 处理优先级,提取语言代码
if (locales.includes(language)) {
return language;
}
}
// 未匹配上,返回默认语言
return i18n.defaultLocale;
}
// 中间件
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// 检查路径是否缺少语言前缀
const pathnameIsMissingLocale = i18n.locales.every(
(locale) => !pathname.startsWith(`/${locale}/`) && pathname !== `/${locale}`
)
// 无语言前缀,重定向
if (pathnameIsMissingLocale) {
const locale = getLocale(request);
return NextResponse.redirect(
new URL(`/${locale}${pathname.startsWith('/') ? '' : '/'}${pathname}`, request.url)
)
}
}
// 配置中间件的匹配规则
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], // 排除 API 请求和静态文件
}
配置后大家可以发现,当请求 http://localhost:3000
的时候,会自动跳转到 /zh
路由,这样可以避免当只访问根路由的时候,丢失语言标识的情况。
我们本文中使用的这种国际化翻译的路由方案叫做 Sub Router
(子路由),语言标识会作为路径的一部分添加到 URL
中,如本文中的 /zh
、/en
。这种方案有以下显著的优势:
- 结构清晰,页面、组件、语言资源共享可以在同一个域名下共享,方便统一管理,适合内容较为相似的多语言站点。
SEO
友好,搜索引擎可以轻松识别不同语言的页面。
提问!除了这种区分语言的方案,还有别的方案吗?
当然有!另一种国际化翻译路由方案叫做 Domain Router
(域名路由),顾名思义,这种方案通过不同的域名来决定该域名下的语言,如 https://www.nextjs.cn
和 https://nextjs.org
的区别,在这种方案下,每个语言都可以有独立的设置、内容和用户体验,不过这种方案要求多域名 + 对应的服务,一般出现在比较强调地域性的海外需求中,理论上在同一个项目中就只是单语言,所以这里我们这里的演示使用 Sub Router
方案。
拉取翻译
既然我们现在可以在服务端的页面上拿到 lang
标识了,那么我们就尝试使用这个标识去拿对应的静态语言配置吧,现在在 /src
文件夹下创建一个 get-dictionary.ts
文件:
ts
// ./src/get-dictionary.ts
import 'server-only'
import type { Locale } from './i18n-config'
const dictionaries = {
zh: async () => import('./dictionaries/zh.json'),
en: async () => import('./dictionaries/en.json'),
ja: async () => import('./dictionaries/ja.json'),
}
export const getDictionary = async (locale: Locale) => {
// 其他拉取策略
// 兜底
if (['zh', 'en', 'ja'].includes(locale)) {
return dictionaries[locale]()
}
return {} as any
}
这里设计的 getDictionary
函数是统一的拉取函数,这个函数会根据标识,拿到 ./src/dictionaries
下的对应的 JSON
文件,并转为对象形式返回回去,然后我们修改一下 page.tsx
文件,让他可以使用 getDictionary
函数来展示正确的内容。我们暂定这个 getDictionary
函数会返回一个普通对象:
tsx
// ./src/app/[lang]/page.tsx
import { getDictionary } from '@/get-dictionary'
import { i18n, Locale } from '@/i18n-config'
import { Metadata } from 'next'
import Link from 'next/link'
export default async function Home({
params: { lang },
}: {
params: { lang: Locale }
}) {
const dictionary = await getDictionary(lang)
return (
<div style={{ padding: 24 }}>
<main>
<h1>
{dictionary.title}
</h1>
<p>
{dictionary.description}
</p>
<div>
{i18n.locales.map((locale) => {
return (
<ol key={locale}>
<Link href={locale}>{locale}</Link>
</ol>
)
})}
</div>
</main>
</div>
)
}
export async function generateMetadata({
params: { lang },
}: {
params: { lang: Locale }
}): Promise<Metadata> {
const dictionary = await getDictionary(lang)
return {
title: dictionary.title,
description: dictionary.description
}
}
成功运行后,我们将获得了上面的页面,为了提升美观性,添加了适当的内边距,这个页面的内容及 head
中的 meta
信息已完成国际化翻译,正确有效的 meta
信息对 SEO
很有帮助。我们现在已经做到了从零搭建一个 Next
的国际化翻译功能,做到目前已经可以初步使用了!但是还是存在一些很明显的可以优化的地方:
- 翻译文件存在本地,需要修改的话非常麻烦,需要变更文件、推代码、发版本。
- 【我是一个「杭州」的前端】,这个杭州两个字我希望可以动态插入,比如类似
dictionary.t('i_am_a_{job}_front_end', { job: '北京' })
的形式。- 我还有浏览器端的页面,所以我希望能有非
await
的方式可以拿到翻译内容的方法。
优化
远程翻译文件
这种远程拉取翻译文件的方案,是需要除了本项目外的其他支持的,因为你希望语言可以做灵活的配置发布的话,让运营同学手改 JSON
是个不现实的事情,也不能很好的维护公司内使用的翻译字典,所以你最好能有一个配置的平台,在这个平台修改翻译的配置后,可以转成 JSON
文件并发布到远程的静态文件文件夹或者是 OSS
等云储存上**(如果大家对这个有兴趣,后续也可以补一下这个项目)**。这里以 ${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}_web.json
来举例,这里的 NEXT_PUBLIC_I18N_HOSTS
被放置在了 env
中防止泄漏,后续大家部署的时候还可以放置在环境变量中。我们来修改一下 getDictionary
函数:
ts
// ./src/get-dictionary.ts
import 'server-only'
import { i18n, type Locale } from './i18n-config'
const dictionaries = {
zh: async () => import('./dictionaries/zh.json'),
en: async () => import('./dictionaries/en.json'),
ja: async () => import('./dictionaries/ja.json'),
}
export const getDictionary = async (locale: Locale) => {
const ossResult = await getDictionaryByOss(locale);
if (ossResult) {
return ossResult
}
if (i18n.locales.includes(locale)) {
return dictionaries[locale]()
}
return {} as any
}
export const getDictionaryByOss = async (locale: Locale) => {
// 无配置跳过拉取
if (!process.env.NEXT_PUBLIC_I18N_HOSTS) return;
// 远程拉取地址
const url = `${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}.json`
try {
const res = await fetch(url)
if (res.ok) {
return await res.json()
}
} catch (error) {}
return;
}
以上代码修改后,如果存在正确的拉取前缀 NEXT_PUBLIC_I18N_HOSTS
,则可在服务运行前就将翻译文件拉取至服务器端,如果不存在,则直接拿本项目内置的 JSON
文件,这里大家可以根据自己的需求来决定拉取的方案,比如我们可以在项目打包的时候,用脚本拉一次所有远程翻译文件至服务器,用这一份"缓存"作为兜底。
动态插入
另外不知道大家有没有发现这么使用翻译 dictionary.title
会存在的几个问题,如果我的翻译 key
是携带空格或者是存在非下划线的字符的话,就只能使用 dictionary['sub title']
或者 dictionary['email@google.com']
的形式,并且如果 title
无内容的时候,会展示 undefined
而不是 key 的原内容!我们接下来就把这个翻译的获取形式,由 dictionary.title
,转为 dictionary('title')
的形式,并且支持类似 dictionary.t('i_am_a_{job}_front_end', { job: '北京' })
的值插入的能力。
ts
// ./src/get-dictionary.ts
import 'server-only'
import { i18n, type Locale } from './i18n-config'
const dictionaries = {
zh: async () => import('./dictionaries/zh.json'),
en: async () => import('./dictionaries/en.json'),
ja: async () => import('./dictionaries/ja.json'),
}
export const getDictionary = async (locale: Locale) => {
const ossResult = await getDictionaryByOss(locale);
if (ossResult) {
return createDictionaryFunction(ossResult);
}
if (i18n.locales.includes(locale)) {
const dict = await dictionaries[locale]()
return createDictionaryFunction(dict as unknown as Record<string, string>);
}
return createDictionaryFunction({});
}
export const getDictionaryByOss = async (locale: Locale) => {
if (!process.env.NEXT_PUBLIC_I18N_HOSTS) return;
const url = `${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}.json`
try {
const res = await fetch(url)
if (res.ok) {
return await res.json()
}
} catch (error) {
console.log(error)
}
return;
}
// 创建字典函数
const createDictionaryFunction = (dictionary: Record<string, string>) => {
return (key: string, variables?: Record<string, string>): string => {
const template = dictionary[key] || key; // 如果没有找到则返回 key
return interpolate(template, variables);
};
};
// 动态插入功能
const interpolate = (template: string, variables?: Record<string, string>): string => {
if (!variables) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] || '');
};
tsx
// ./src/app/[lang]/page.tsx
import { getDictionary } from '@/get-dictionary'
import { i18n, Locale } from '@/i18n-config'
import { Metadata } from 'next'
import Link from 'next/link'
export default async function Home({
params: { lang },
}: {
params: { lang: Locale }
}) {
const t = await getDictionary(lang)
return (
<div style={{ padding: 24 }}>
<main>
<h1>
{t('title')}
</h1>
<p>
{t('description')}
</p>
<p>
{t('welcome to {posi}, {name}', { posi: 'Next.js', name: 'Cooyue' })}
</p>
<p>
{t('这是不存在翻译的文本')}
</p>
<div>
{i18n.locales.map((locale) => {
return (
<ol key={locale}>
<Link href={locale}>{locale}</Link>
</ol>
)
})}
</div>
</main>
</div>
)
}
export async function generateMetadata({
params: { lang },
}: {
params: { lang: Locale }
}): Promise<Metadata> {
const t = await getDictionary(lang)
return {
title: t('title'),
description: t('description')
}
}
修改完毕并执行后,大家可以在 http://localhost:3000/zh
下看到如下内容,可以看到额外展示了【欢迎来到 Next.js, Cooyue 】的字样,在这个例子中,posi
、name
参数都可以自定义传入,并且支持无翻译时展示原 key
。
hooks
那么我们在客户端如何去使用国际化翻译的文件呢?现在我们来创建一个客户端的组件 LocaleSwitcher
,并把这个组件置入 page.tsx
文件中,我们将用这个组件来演示客户端的翻译内容获取。
tsx
// ./src/components/locale-switcher.tsx
'use client'
import { usePathname } from 'next/navigation'
import Link from 'next/link'
import { i18n } from '@/i18n-config'
import { useDictionary } from '@/hooks/useDictionary'
export default function LocaleSwitcher() {
const pathName = usePathname()
const t = useDictionary()
const redirectedPathName = (locale: string) => {
if (!pathName) return '/'
const segments = pathName.split('/')
segments[1] = locale
return segments.join('/')
}
return (
<div>
<p>这里客户端组件的翻译结果:{t('title')}</p>
<ul>
{i18n.locales.map((locale) => {
return (
<li key={locale}>
<Link href={redirectedPathName(locale)}>{locale}</Link>
</li>
)
})}
</ul>
</div>
)
}
tsx
// ./src/app/[lang]/page.tsx
import LocaleSwitcher from '@/components/locale-switcher'
import { getDictionary } from '@/get-dictionary'
import { Locale } from '@/i18n-config'
import { Metadata } from 'next'
export default async function Home({
params: { lang },
}: {
params: { lang: Locale }
}) {
const t = await getDictionary(lang)
return (
<div style={{ padding: 24 }}>
<main>
<h1>
{t('title')}
</h1>
<p>
{t('description')}
</p>
<p>
{t('welcome to {posi}, {name}', { posi: 'Next.js', name: 'Cooyue' })}
</p>
<p>
{t('这是不存在翻译的文本')}
</p>
<LocaleSwitcher />
</main>
</div>
)
}
export async function generateMetadata({
params: { lang },
}: {
params: { lang: Locale }
}): Promise<Metadata> {
const t = await getDictionary(lang)
return {
title: t('title'),
description: t('description')
}
}
现在我们着重来处理这个 useDictionary
函数!这个由于是客户端组件的原因,所以我们不能用异步函数的形式来写,所以我们考虑他的初始状态应该是以下这个样子的 Hooks
,下面的一小串代码是不可用的,我们只看思路:
tsx
// 示例代码,只用于看思路
export const useDictionary = () => {
// 获取语言,不希望每次都需要外部手动获取语言
const locale = useLocale()
// 获取 OSS 的 hooks,这个 hooks 应该在 locale 变动的时候只请求一次
const ossResult = useDictionaryByOss();
// 如果存在返回的内容,直接使用
if (ossResult) {
// 某种处理翻译对象 + 自定义参数,这个函数最后需要返回一个函数供外部使用
return createDictionaryFunction(ossResult);
}
// 获取远程文件错误,确认语言是否合法,拿对应的项目文件
if (i18n.locales.includes(locale)) {
const dict_map = {
'zh': ZH_CONFIG,
'en': EN_CONFIG,
'ja': JA_CONFIG,
}
const dict = dict_map[locale] as Record<string, string>
return createDictionaryFunction(dict);
}
// 兜底
return createDictionaryFunction({});
}
这里需要注意的是 useDictionaryByOss
的性能,必须只在语言存在变动的时候只请求一次,另外 createDictionaryFunction
函数需要创建一个外部可使用的函数,所以我们可以根据这个思路得出以下 Hooks
代码:
ts
// ./src/hooks/useDictionary.ts
import { useEffect, useState } from "react"
import { i18n, Locale } from "@/i18n-config"
import { usePathname } from "next/navigation"
import ZH_CONFIG from '../dictionaries/zh.json'
import EN_CONFIG from '../dictionaries/en.json'
import JA_CONFIG from '../dictionaries/ja.json'
export const useLocale = () => {
const pathName = usePathname()
if (!pathName) return i18n.defaultLocale
return pathName.split('/')[1] as Locale
}
export const useDictionary = () => {
const locale = useLocale()
const ossResult = useDictionaryByOss();
if (ossResult) {
return createDictionaryFunction(ossResult);
}
if (i18n.locales.includes(locale)) {
const dict_map = {
'zh': ZH_CONFIG,
'en': EN_CONFIG,
'ja': JA_CONFIG,
}
const dict = dict_map[locale] as Record<string, string>
return createDictionaryFunction(dict);
}
return createDictionaryFunction({});
}
const useDictionaryByOss = () => {
const [result, setResult] = useState<Record<string, string> | null>(null)
const locale = useLocale()
useEffect(() => {
if (!process.env.NEXT_PUBLIC_I18N_HOSTS) return;
const url = `${process.env.NEXT_PUBLIC_I18N_HOSTS}/article/${locale}.json?${Date.now()}`
fetch(url)
.then(res => res.json())
.then(setResult)
.catch(() => setResult(null))
}, [locale])
return result
}
// 创建字典函数
const createDictionaryFunction = (dictionary: Record<string, string>) => {
return (key: string, variables?: Record<string, string>): string => {
const template = dictionary[key] || key; // 如果没有找到则返回 key
return interpolate(template, variables);
};
};
const interpolate = (template: string, variables?: Record<string, string>): string => {
if (!variables) return template;
return template.replace(/\{(\w+)\}/g, (_, key) => variables[key] || '');
};
修改完毕后,我们可以发现在 ./src/components/locale-switcher.tsx
这个客户端组件中,能成功拿到翻译的内容。
尾声
至此我们已经完成了,为 Next
项目添加国际化翻译的功能,并通过这个案例来系统地思考过程中遇到的问题与优化点,以及解决的思路,以下是本文涉及到的相关链接,本文的项目已经部署到了 Vercel。
相关链接:
Demo 访问地址 github地址 客户端组件 动态 API 路由 NextJS middleware Next Intl NextJS 官方文档 NextJS 中文文档
我是 Cooyue柯同学
,欢迎关注我~