一篇文章学会 Next.js 实现 i18n 国际化多语言(基于App Router)

文章导读:

本文介绍了在 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
53
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