JackyLove 的技术人生

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

第25章—路由篇国际化

首次发表于 2024-03-22, 更新于 2024-03-22

前言

前面我们都在讲 App Router 带来的强大功能,但也不总是会更加便捷,就比如国际化的处理相比以前 Pages Router 反而复杂了一点,需要借助上节讲到的中间件进行来实现。

本篇我们会介绍 Next.js 的国际化实现方式,并为大家普及国际化的基础术语和概念,考虑到并不是所有的同学都有国际化需求,本篇内容可以选择性学习。

1. 相关术语

1.1. 国际化与本地化

Internationalization,简写 i18n(首末字符 i 和 n,18 为中间的字符数),中文译为“国际化”。引用百度百科的解释:

在资讯领域,国际化(i18n)指让产品(出版物,软件,硬件等)无需做大的改变就能够适应不同的语言和地区的需要。对程序来说,在不修改内部代码的情况下,能根据不同语言及地区显示相应的界面。

与 Internationalization 相关的一个单词叫做 localization,简写 L10n,中文译为“本地化”。

在信息技术领域,国际化与本地化是指修改软件使之能适应目标市场的语言、地区差异以及技术需要。

国际化是指在设计软件,将软件与特定语言及地区脱钩的过程。当软件被移植到不同的语言及地区时,软件本身不用做内部工程上的改变或修正。本地化则是指当移植软件时,加上与特定区域设置有关的信息和翻译文件的过程。

国际化和本地化之间的区别虽然微妙,但却很重要。国际化意味着产品有适用于任何地方的“潜力”;本地化则是为了更适合于“特定”地方的使用,而另外增添的特色。用一项产品来说,国际化只需做一次,但本地化则要针对不同的区域各做一次。这两者之间是互补的,并且两者合起来才能让一个系统适用于各地

简而言之,“国际化”是“本地化”的一部分,主要是指国际化的实现机制和翻译工作, “本地化”包含“国际化”,是对“国际化”的补充和完善,它还包括为实现对某种特定语言良好的支持而进行的有针对性的翻译调整以及对软件进行的打补丁工作。

这样说有些抽象,举个具体的例子,当访问 /dashboard的时候,默认显示中文,当访问 /en/dashboard 的时候,显示英文,当访问 /fr/dashboard的时候,显示法语,这套逻辑的实现属于国际化。

比如要进军阿拉伯市场,但阿拉伯语是从右到左(RTL)的语言,这就可能需要你重新设计界面,这就属于本地化。调整时间日期格式、货币、文化图片、符合、手势等等也都属于本地化。

1.2. locale

讲解完国际化和本地化,我们再说一个术语 —— locale。你可以把它理解为软件运行时的语言环境,它是一组语言和格式首选项的标识符。locale 的命名规则为:

language[_territory[.codeset]][@modifier]

翻译成中文:

 [语言[_地域][.字符集] [@修正值]

其中 language 是 ISO 639-1 标准中定义的双字母的语言代码,territory 是 ISO 3166-1 标准中定义的双字母的国家和地区代码,codeset 是字符集的名称 (如 UTF-8等),而 modifier 则是某些 locale 变体的修正符。

以汉语为例,zh_CN.GB2312就表示中国地区的汉语,字符集采用 GB2312。

冷知识:

  1. 英语用 en 表示,取自于 English,德语用 de 表示,取自于 Deutsch,这是“德语”的德语,汉语用 zh 表示,取自于“中文”的汉语拼音:Zhōngwén,但也不总是如此,比如日语用 ja 表示,尽管日语拼音是 Nihongo。
  2. 除了 zh_CN 还有 zh-HK(中国香港)、zh-SG(新加坡)、zh-TW(中国台湾)。

2. 实现方式

Next.js 可以让你通过配置路由和渲染内容支持多种语言,让我们看看怎么实现吧。

2.1. 判断区域设置

首先 Next.js 推荐使用浏览器中的语言首选项来判断要使用的区域设置,为此你需要分析传入的请求,确定要使用的区域设置。我们知道,请求头中是有 Accept-Language这个标头的,我们就可以根据这个字段的值来确定。

截屏2023-11-22 下午9.06.57.png

为了方便分析,我们可以借助一些库来实现,比如:

// middleware.js
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
 
let headers = { 'accept-language': 'en-US,en;q=0.5' }
let languages = new Negotiator({ headers }).languages()
let locales = ['en-US', 'nl-NL', 'nl']
let defaultLocale = 'en-US'
 
match(languages, locales, defaultLocale) // -> 'en-US'

首先是 negotiator 这个库,将请求作为参数传给 Negotiator 构造函数,就可以通过 new Negotiator(request).languages() 快速获取支持的语言,比如请求的 accept-language 为 'en;q=0.8, es, pt'new Negotiator(request).languages() 的值为 ['es', 'pt', 'en']

其次是 @formatjs/intl-localematcher 这个库,它的 match 函数,顾名思义,帮助匹配出最适合的语言,比如:

// middleware.js
// match(languages, locales, defaultLocale)

// 结果为 'fr',因为 locales 里只有 fr 和 en
match(['fr-XX', 'en'], ['fr', 'en'], 'en')

// 结果为 'en',因为 locales 里没有 zh,所以使用了 defaultLocale
match(['zh'], ['fr', 'en'], 'en')

2.2. 中间件处理

找到了合适的 locale,现在我们就可以根据 locale 来实现子路径(/fr/products)或者域(my-site.fr/products)国际化,也就是根据用户的浏览器语言设置对应跳转到如 /fr/products这样的国际化路由地址。示例代码如下:

// middleware.js
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'

let locales = ['en-US', 'nl-NL', 'nl']
const defaultLocale = 'en-US'

function getLocale(request) { 
  const headers = { 'accept-language': request.headers.get('accept-language') || '' };
  // 这里不能直接传入 request,有更简单的写法欢迎评论留言
  const languages = new Negotiator({ headers }).languages();

  return match(languages, locales, defaultLocale)
 }
 
export function middleware(request) {
  const { pathname } = request.nextUrl
  // 判断请求路径中是否已存在语言,已存在语言则跳过
  const pathnameHasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  )
 
  if (pathnameHasLocale) return
 
  // 获取匹配的 locale
  const locale = getLocale(request)
  request.nextUrl.pathname = `/${locale}${pathname}`
  // 重定向,如 /products 重定向到 /en-US/products
  return Response.redirect(request.nextUrl)
}
 
export const config = {
  matcher: [
    // 跳过所有内部路径 (_next)
    '/((?!_next).*)',
    // 可选: 仅在根 URL (/) 运行
    // '/'
  ],
}

最后,因为添加上述代码后比如访问 /dashboard,会跳转到 /en-US/dashboard,而此时并没有对应的处理程序,所以还要将 app/下所有的特殊文件都放在 app/[lang]下。因为借助了动态路由,lang 参数会被转发给每个布局和页面,你可以在布局或页面中获取到 lang 参数:

// app/[lang]/page.js
export default async function Page({ params: { lang } }) {
  return ...
}

根布局也可以放在新文件中,如 app/[lang]/layout.js

2.3. 本地化

现在我们来解决翻译的问题,本质是根据用户的区域设置来改变显示的内容,但解决的模式并不算是 Next.js 中的特殊内容,任何 Web 应用程序都可以这样解决。

假设我们希望应用能够同时支持英语和荷兰语,我们可以维护两个不同的字典,字典会提供从某个键到本地化字符串的映射,例如:

// dictionaries/en.json
{
  "products": {
    "cart": "Add to Cart"
  }
}
// dictionaries/nl.json
{
  "products": {
    "cart": "Toevoegen aan Winkelwagen"
  }
}

然后我们可以创建一个 getDictionary 函数加载对应语言的字典:

import 'server-only'
 
const dictionaries = {
  en: () => import('./dictionaries/en.json').then((module) => module.default),
  nl: () => import('./dictionaries/nl.json').then((module) => module.default),
}
 
export const getDictionary = async (locale) => dictionaries[locale]()

最后根据当前选择的语言,在布局或者页面中获取字典,展示对应的翻译文字:

// app/[lang]/page.js
import { getDictionary } from './dictionaries'
 
export default async function Page({ params: { lang } }) {
  const dict = await getDictionary(lang) // en
  return <button>{dict.products.cart}</button> // Add to Cart
}

由于 app/ 目录下的所有布局和页面默认都是服务端组件,所以不用担心翻译文件的大小会影响客户端 JavaScript bundle 的大小,这段代码只会在服务端上运行,并且只有生成的 HTML 会发送到浏览器。

2.4. 静态生成

如果要为一组区域设置生成静态路由,我们可以在布局或页面中使用 generateStaticParams。可以在全局中使用,比如这个例子就是在根布局中:

// app/[lang]/layout.js
export async function generateStaticParams() {
  return [{ lang: 'en-US' }, { lang: 'de' }]
}
 
export default function Root({ children, params }) {
  return (
    <html lang={params.lang}>
      <body>{children}</body>
    </html>
  )
}

小结

恭喜你,完成了本节内容的学习!

这一节我们介绍了国际化相关的术语概念,然后讲解了 Next.js 中的国际化实现方式。首先 Next.js 推荐使用浏览器中的语言首选项来判断要使用的区域设置,为此你需要分析传入的请求,确定要使用的区域设置。

然后借助中间件实现路由的重定向,将 /dashboard 重定向到如 /en-US/dashboard这样的地址,此外还需要借助字典模式实现语言的本地化工作。

参考链接

  1. I18N
  2. wiki/区域设置
  3. Routing: Internationalization | Next.js
© Copyright 2025 JackyLove 的技术人生. Powered with by CreativeDesignsGuru