0%

文章导读:

本文介绍了在 Next.js 14 (基于App Router) 中实现 i18n 国际化多语言功能,并考虑在真实的场景中,一步步优化将功能完善。通过阅读完本文,你将立即掌握如何在 Next.js 中实现 i18n。

hero

前言

在互联网世界越来越扁平化的时代,产品的多语言显得越来越重要。幸运的在 Next.js 中通过简单的配置和代码即可快速支持多语言。但是,当我们在互联网上搜索 Next.js 如何支持多语言时,可能会看到各种实现方式、鱼龙混杂和奇技淫巧的方案,于是我们一头雾水,不禁怀疑人生:到底哪里出了问题?

今天,让我们从 0 到 1 在 Next.js 中实现一个多语言,揭开多语言的神秘面纱。

我们查看 Next.js 官方文档中的 i18n 介绍, https://nextjs.org/docs/app/building-your-application/routing/internationalization,比较清晰详细了,本文也将基于此篇文档制作。

开始之前,先看看最终运行效果:https://next-i18n-demo-two.vercel.app/

准备工作

首先,我们初始化一个 Next.js app,

1
npx create-next-app@latest

请注意选择 App Router,此处我使用的是 TypeScript

1
2
3
4
5
6
7
8
❯ npx create-next-app@latest
✔ What is your project named? … `next-i18n-demo`
✔ Would you like to use TypeScript? … No / `Yes`
✔ Would you like to use ESLint? … No / `Yes`
✔ Would you like to use Tailwind CSS? … No / `Yes
✔ Would you like to use `src/` directory? … No / `Yes`
✔ Would you like to use App Router? (recommended) … No / `Yes`
✔ Would you like to customize the default import alias (@/*)? … `No` / Yes

本地启动,

1
npm run dev

打开 http://localhost:3000 看到程序运行正常。

国际化介绍

在正式开始之前,我们先简单介绍一下国际化,国际化 internationalization,简称 i18n,也即在产品中支持多国语言文化和环境风俗,主要包括语言/时间/货币符号等。这篇文章中将只专注于语言部分。

在国际化的具体呈现上,常见的方式是网站默认进入某个语言的官网(通常是英文),并支持选择语言或地区,进行切换网站的不同语言版本。

具体实现方式上,有的网站以语言简称为前缀,如 en.wikipedia.org, zh.wikipedia.org;有的网站以语言简称作为路径后缀,如 aws.amazon.com/cnaws.amazon.com/jp,也有以国家地区域名为区分的,如以前的 apple.cn, apple.jp

其中诸如 en, zh, cn, jp ,也即语言编码,在不同版本的语言编码版本中略有不同,具体可参考文章下方参考资料。

在本文案例中,将以 ISO_3166 中的 enzh 编码分别代表英文和中文。

开始配置多语言

项目之前的文件结构:

1
2
3
4
5
6
7
8
9
10
11
12
├── package.json
├── public
│   ├── next.svg
│   └── vercel.svg
├── src
│   └── app
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── tailwind.config.ts
└── tsconfig.json

我们在 app 目录新建一个文件夹 [lang],然后将 app 目录的 laytout.tsxpage.tsx 移入 [locales]中,

移动后的文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
├── package.json
├── postcss.config.mjs
├── public
│   ├── next.svg
│   └── vercel.svg
├── src
│   └── app
│   ├── [lang]
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── favicon.ico
│   └── globals.css
├── tailwind.config.ts
└── tsconfig.json

Tips:

注意同步修改 layout.tsx 中 globals.css 的引用位置。

接下来,我们定义不同语言的 json 资源文件,你可以放入你习惯的文件目录,我这里放入 public/dictionaries,格式如下:

en.json

1
2
3
4
5
6
7
8
9
10
{
"page": {
"title": "Next.js i18n Demo",
"desc": "How to implement i18n with Next.js (based on App Router)"
},
"home": {
"title": "Hello, Next.js i18n",
"desc": "This is a demo of Next.js i18n"
}
}

zh.json

1
2
3
4
5
6
7
8
9
10
{
"page": {
"title": "Next.js i18n 示例",
"desc": "搞懂 Next.js 实现 i18n 国际化多语言(基于App Router)"
},
"home": {
"title": "你好, Next.js i18n",
"desc": "这是一个 Next.js i18n 示例"
}
}

紧接着,我们创建一个文件,用于加载多语言资源文件并获取相应语言文本。

app/[lang] 目录添加 dictionaries.js,注意检查文件目录及文件名是正确并匹配的。

1
2
3
4
5
6
7
8
import 'server-only'

const dictionaries = {
en: () => import('./dictionaries/en.json').then((module) => module.default),
zh: () => import('./dictionaries/zh.json').then((module) => module.default),
}

export const getDictionary = async (locale) => dictionaries[locale]()

使用多语言

我们在 pages.tsx 页面中使用多语言功能。

首先,为函数增加 lang 参数,注意为函数添加 async 关键字,

1
2
3
export default async function Home({ params: { lang } }: { params: { lang: string } }) {
...
}

添加多语言的调用,

1
const t = await getDictionary(lang);

在页面上使用,为了方便我将 page.tsx 上默认的代码进行清理,只保留文本展示。

1
2
3
4
5
6
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<p className="fixed left-0 top-0 flex w-full justify-center border-b border-gray-300 bg-gradient-to-b from-zinc-200 pb-6 pt-8 backdrop-blur-2xl dark:border-neutral-800 dark:bg-zinc-800/30 dark:from-inherit lg:static lg:w-auto lg:rounded-xl lg:border lg:bg-gray-200 lg:p-4 lg:dark:bg-zinc-800/30">
{t.home.title}
</p>
{t.home.desc}
</main>

重启程序或等程序热更新成功,分别打开不同语言的页面 http://localhost:3000/en http://localhost:3000/zh 即可看到效果。

设置默认语言

看起来不错,但是细心的朋友会发现打开 http://localhost:3000 会出现 404 error。为了解决这个问题,我们需要在未选择语言时,默认设置一个语言。

为此,我们可以在 src 目录创建一个 middleware.ts ,然后复制文档中的代码。

核心逻辑很简单:

判断 URL 的 pathname 中是否含有某个语言标识,如果有则直接返回,否则在获取合适的语言后,将 URL 重定向为 /${locale}${pathname}

重点在 getLocale 函数中,我们需要指定合适的语言。在此处,我们先简单处理:使用默认的 defaultLocale = "en"

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import { NextRequest, NextResponse } from "next/server";

let locales = ["en", "zh"];
let defaultLocale = "en";

// Get the preferred locale, similar to the above or using a library
function getLocale(request: NextRequest) {
return defaultLocale;
}

export function middleware(request: NextRequest) {
// Check if there is any supported locale in the pathname
const { pathname } = request.nextUrl;
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);

if (pathnameHasLocale) return;

// Redirect if there is no locale
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
// e.g. incoming request is /products
// The new URL is now /en-US/products
return NextResponse.redirect(request.nextUrl);
}

export const config = {
matcher: [
// Skip all internal paths (_next)
"/((?!_next).*)",
// Optional: only run on root (/) URL
// '/'
],
};

程序更新后,我们打开 http://localhost:3000/ 可以看到会自动跳转到设置的默认语言页面。

获取默认语言的优化

在上一节获取默认语言时,我们简单处理为 defaultLocale = "en" ,更优雅的方式是:根据用户的系统或者浏览器的语言来设置默认语言

我们可以通过获取浏览器 HTTP headers 中的 Accept-Language 字段来达到目的。它的数据格式大致如下:

1
2
3
4
英文时:
accept-language: en-US,en;q=0.5
中文时:
accept-language: zh-CN,zh-Hans;q=0.9

我们将 middleware 改造如下:

  1. 从 HTTP headers 中获取 Accept-Language,如果为空则返回默认语言
  2. 解析 Accept-Language 中的语言列表,并根据配置的语言列表,匹配获取对应的语言(如果没有则返回默认语言)

安装依赖 @formatjs/intl-localematcher, negotiator, @types/negotiator,并实现如下逻辑:

1
2
3
4
5
6
7
function getLocale(request: NextRequest) {
const acceptLang = request.headers.get("Accept-Language");
if (!acceptLang) return defaultLocale;
const headers = { "accept-language": acceptLang };
const languages = new Negotiator({ headers }).languages();
return match(languages, locales, defaultLocale);
}

通过修改系统的语言,打开 http://localhost:3000 会自动跳转到同系统语言一致的页面,测试成功。

多语言的其它处理

存储用户网页语言

更进一步地,我们可以在 Cookie 中存储用户网页中的语言,并在下次访问时使用:

1
2
3
4
5
6
7
// 获取Cookie 
if (request.cookies.has(cookieName)) {
return request.cookies.get(cookieName)!.value;
}

// 设置 Cookie
response.cookies.set(cookieName, locale);

网页标题描述等的多语言处理

在网页 metadata 中使用多语言时,page.tsx添加如下代码:

1
2
3
4
5
6
7
export async function generateMetadata({ params: { lang } } : { params: { lang: string } }) {
const t = await getDictionary(lang);
return {
title: t.page.title,
description: t.page.desc,
};
}

SSG 的多语言处理

在处理静态站点(SSG)中使用多语言时,layout.tsx代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
interface LangParams {
lang: string;
}

export async function generateStaticParams() {
return [{ lang: "en" }, { lang: "zh" }];
}

export default function RootLayout({
children,
params,
}: Readonly<{
children: React.ReactNode;
params: LangParams;
}>) {
return (
<html lang={params.lang}>
<body className={inter.className}>{children}</body>
</html>
);
}

切换多语言(语言选择器或链接)

可根据实际情况添加语言选择器(下拉框)或不同的链接,从而跳转到对应语言的页面。

例如通过链接实现多语言切换:

1
2
3
4
5
<div className="space-x-2">
<Link href="/en">English</Link>
<span>|</span>
<Link href="/zh">Chinese</Link>
</div>

尾声

通过上述步骤的学习,我们初步熟悉并实践了在 Next.js 中使用多语言。千里之行,始于足下,国际化的工作不止于此,我们当然也还有尚未完善的地方,就留给屏幕前的你吧。

最后附上 middleware.ts 完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import Negotiator from "negotiator";
import { match } from "@formatjs/intl-localematcher";
import { NextRequest, NextResponse } from "next/server";

const locales = ["en", "zh"];
const defaultLocale = "zh";
const cookieName = "i18nlang";

// Get the preferred locale, similar to the above or using a library
function getLocale(request: NextRequest): string {
// Get locale from cookie
if (request.cookies.has(cookieName))
return request.cookies.get(cookieName)!.value;
// Get accept language from HTTP headers
const acceptLang = request.headers.get("Accept-Language");
if (!acceptLang) return defaultLocale;
// Get match locale
const headers = { "accept-language": acceptLang };
const languages = new Negotiator({ headers }).languages();
return match(languages, locales, defaultLocale);
}

export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith("/_next")) return NextResponse.next();

// Check if there is any supported locale in the pathname
const { pathname } = request.nextUrl;
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);

if (pathnameHasLocale) return;

// Redirect if there is no locale
const locale = getLocale(request);
request.nextUrl.pathname = `/${locale}${pathname}`;
// e.g. incoming request is /products
// The new URL is now /en-US/products
const response = NextResponse.redirect(request.nextUrl);
// Set locale to cookie
response.cookies.set(cookieName, locale);
return response;
}

export const config = {
matcher: [
// Skip all internal paths (_next)
"/((?!_next).*)",
// Optional: only run on root (/) URL
// '/'
],
};

完整代码可在 https://github.com/xumeng/next-i18n-demo 获取。

最终运行效果:https://next-i18n-demo-two.vercel.app/


参考资料:

https://nextjs.org/docs/app/building-your-application/routing/internationalization

https://en.wikipedia.org/wiki/ISO_3166

https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes

https://en.wikipedia.org/wiki/IETF_language_tag

https://www.alchemysoftware.com/livedocs/ezscript/Topics/Catalyst/Language.htm

拖着行李箱的人们,

满载着一年的劳累,

穿过长长的铁道,

回到母亲怀内,

妈妈

我想再睡会

2024.2.7 腊月二十八

里面,

取号,
排队,
叫号,
付费。

一切为了活着。


外面,

工作,
生活,
享乐,
受罪。

活着为了一切。

早高峰的洪流中,
我被人群簇拥着,
仿佛生命之王。

我感觉自己,
活着。
但又感觉,
死了。

ChatGPT辅助创作


机器上使用了多个 Git 库,比如 GitHub/公司 Git 库等,Git 工具主要使用 Terminal 和 GitKraken,偶尔会出现奇怪的问题。比如前两天在部署 Hexo Blog 时,遇到以下报错:

1
2
3
4
5
Permission denied (publickey).
fatal: Could not read from remote repository.

Please make sure you have the correct access rights
and the repository exists.

通常的解决方案是重新生成 GitHub 的 SSH Key。

但是我本地的配置是没问题的,可以在 Terminal 和GitKraken正常提交代码。

猜想可能是哪个环节不通,于是使用:

1
ssh -T git@github.com

看到:

1
Hi {username}! You've successfully authenticated, but GitHub does not provide shell access.

再重新部署提交,搞定!

参考:

https://docs.github.com/en/authentication/troubleshooting-ssh/error-permission-denied-publickey

任务堆积如山,
债务堆积如山,
茫茫一片看不见。

我,
堆积如山。

列车从远方,
卸下年轻人,
拉走父母妻儿,
和中老年。

他们身前,
是扬起的帆,
他们身后,
是累累的山。

一头野兽,
在地下怒吼狂奔,
他吞掉情侣夫妻父母儿女,
吐出一个个沉默的打工人。

8年前的一天,在一家粤菜店吃不是饭点的午饭时,我一边品着大麦茶,一边对着左顾右盼着餐厅不多的客人。

餐厅播放着无聊的节目,但当我转头看向电子时钟,上面跳动着猩红色的数字:2014年11月27日。

恍惚间,这年这月这日组成这一串,于我陌生无比,不知是外星数字来了地球,还是我这地球人去了外星。

2-0-1-4,2014年?我印象中并没有这么个年,要说有的,大概是些刚毕业时上大学,刚高考完读高中,刚升高中读初中,升初中时读小学的那些年。

1-1-2-7,11月27日?还早呢。我并没有十一长假,也未曾过过中秋,说起端午,粽子也没吃过,五一呢,我倒没什么记忆深刻的事情。

现在,又是11月27日。

曾不知在哪听过一经典名句:程序员的工作有两件事,一是写 Bug,二是找 Bug。

说归说笑归笑,奈何话糙理不糙:在程序员的工作中,Bug的排查分析和解决验证确实占相当可观的一部分时间。
那么说到,而在真正排查 Bug 时,才深刻体会到另一名句:不写日志一时爽,排查 Bug 火Z场。

日志管理,一直是开发人员的老大难题。这个老大难题,大致分为几块内容:

1. 打印日志

狭义上的日志管理,也即打印日志。套用 3W1H 分析方法可以分为几个子问题:

  1. Why 为什么要打日志
    显而易见,日志是记录关键信息和数据的地方,以备未来排查问题和数据统计分析之用。
  2. What 要打什么样的日志

  3. Where/When 在哪里/什么时候打日志

  4. How 怎么打日志

2. 记录日志

3. 查看日志