我需要为我的 nextjs 项目添加 i18n 功能,从而更好地进行推广和服务。于是我尝试使用 next-intl
库来完成这个功能,本文记录了我接入这个库的整个过程。
首先安装依赖 next-intl
,在根目录下创建对应的目录:
├── messages
│ ├── en.json (1)
│ └── ...
├── next.config.mjs (2)
└── src
├── i18n
│ ├── routing.ts (3)
│ └── request.ts (5)
├── middleware.ts (4)
└── app
└── [locale]
├── layout.tsx (6)
└── page.tsx (7)
其次,创建翻译文件 messages/en.json
和其他语言的翻译文件:
// en.json
{
"HomePage": {
"title": "Aaron's Chrome Extension Store",
"description": "A collection of Chrome extensions created by Aaron.",
"hello": "Hello, world!"
},
"Metadata": {
"title": "Aaron's Chrome Extension Store",
"description": "A curated store of excellent extensions maintained and independently developed by Aaron.",
"keywords": "Chrome extensions, Aaron, extension store"
}
}
// zh.json
{
"HomePage": {
"title": "亚伦的 Chrome 扩展商店",
"description": "亚伦创建的 Chrome 扩展集合。",
"hello": "你好,世界!"
},
"Metadata": {
"title": "亚伦的 Chrome 扩展商店",
"description": "亚伦精心维护和独立开发的优秀扩展程序商店。",
"keywords": "Chrome 扩展, 亚伦, 扩展商店"
},
"Locales": {
"en": "英语",
"es": "西班牙语",
"fr": "法语",
"zh": "中文"
},
"Common": {
"language": "语言"
}
}
再次,配置 next.config.ts
文件:
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default withNextIntl(nextConfig);
然后,配置 i18n
的路由文件 src/i18n/routing.ts
:
import {defineRouting} from 'next-intl/routing';
import {createNavigation} from 'next-intl/navigation';
export const routing = defineRouting({
// A list of all locales that are supported
locales: ['en', 'zh'],
// Used when no locale matches
defaultLocale: 'en'
});
// Lightweight wrappers around Next.js' navigation APIs
// that will consider the routing configuration
export const {Link, redirect, usePathname, useRouter} =
createNavigation(routing);
再然后,创建中间件 src/middleware.ts
:
import createMiddleware from 'next-intl/middleware';
import {routing} from './i18n/routing';
export default createMiddleware(routing);
export const config = {
// Match only internationalized pathnames
matcher: ['/', '/(zh|en)/:path*']
};
由 next-intl
提供的创建中间件的函数配合多语言的路由,即可创建一个中间件。与此同时,导出的 config
对象限定了这个中间件的匹配范围。
然后,我们还需要创建一个 src/i18n/request.ts
文件来提供一个函数来获取语言和对应语言的翻译文件。
import {getRequestConfig} from 'next-intl/server';
import {routing} from './routing';
export default getRequestConfig(async ({requestLocale}) => {
// This typically corresponds to the `[locale]` segment
let locale = await requestLocale;
// Ensure that a valid locale is used
if (!locale || !routing.locales.includes(locale as any)) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
};
});
接下来,创建 src/app/[locale]/layout.tsx
布局组件:
import { routing } from '@/i18n/routing';
import { NextIntlClientProvider } from 'next-intl';
import { getMessages } from 'next-intl/server';
import localFont from "next/font/local";
import { notFound } from 'next/navigation';
import "../globals.css";
import SiteHeader from '../view/SiteHeader';
const geistSans = localFont({
src: "../fonts/GeistVF.woff",
variable: "--font-geist-sans",
weight: "100 900",
});
const geistMono = localFont({
src: "../fonts/GeistMonoVF.woff",
variable: "--font-geist-mono",
weight: "100 900",
});
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
// Ensure that the incoming `locale` is valid
if (!routing.locales.includes(locale as any)) {
notFound();
}
// Providing all messages to the client
// side is the easiest way to get started
const messages = await getMessages();
return (
<html lang={locale}>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<NextIntlClientProvider messages={messages}>
<SiteHeader />
{children}
</NextIntlClientProvider>
</body>
</html>
);
}
这里我们提供了一个 NextIntlClientProvider
组件,将翻译信息传递了下去。
其中 <SiteHeader>
单独拆成一个组件,是因为 useTranslations
钩子必须在非异步组件内使用。
这里我将默认的 src/app/layout.tsx
删掉了,然后在 src/app/page.tsx
中做了重定向:
import { redirect } from 'next/navigation';
// This page only renders when the app is built statically (output: 'export')
export default function RootPage() {
redirect('/en');
}
访问根目录时自动重定向到 /en
路由下,并且经过中间件的处理。
进入到不同的路由下会使得当前的语言被写入到客户端
cookie
中的NEXT_LOCALE
字段,下次请求时带上此字段,next-intl
就可以确定语言了。
现在来编写 src/app/[locale]/page.tsx
:
import { useTranslations } from 'next-intl';
export default function HomePage() {
const t = useTranslations('homepage');
return (
<div className='flex flex-col items-center m-12 gap-4'>
<h1>{t('hello')}</h1>
</div>
);
}
此时,我们进入 /en
和 /zh
两个路由下的页面如下:
中文:
英文:
目前版本的 next-intl
仅在服务器组件中使用 useTranslations
来实现多语言,未来将会消除这个限制,让客户端组件也能自由切换多语言。
在静态渲染时,next-intl
提供了一个临时的 api 来启用多语言功能。
由于我们使用了 [locale]
动态路由,因此我们需要通过 generateStaticParams
函数,将所有可能的语言路由传给 nextjs
,以便于在构建时渲染。
在 app/[locale]/layout.tsx
组件中添加代码:
export function generateStaticParams() {
return routing.locales.map((locale) => ({locale}));
}
显然 routing
来源于我们的 i18n/routing
,声明了我们支持的哪些语言。
此外,我们还需要在布局组件中显式地声明启用静态渲染多语言:
...
...
export default async function LocaleLayout({
children,
params
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
...
// Enable static rendering
setRequestLocale(locale);
...
}
我们需要在要启用静态渲染的每个页面和每个布局中调用此函数,因为 Next.js 可以独立渲染布局和页面。
setRequestLocale
函数让 nextjs
知悉当前页面的语言。
这听起来有点模糊,目前 nextjs
并没有提供任何 API 来从路由参数中读取类似 locale 这样的内容。而语言是 next-intl
的关键信息,这部分功能只能通过 next-intl
来增强。
关键点:
举个例子:
app/
├── [locale]/ # 语言路由必须在这一层
│ ├── layout.tsx # ✅ 在这里调用 setRequestLocale
│ ├── page.tsx
│ └── about/
│ └── page.tsx # ❌ 不需要重复调用,已被 [locale]/layout.tsx 覆盖
│
└── layout.tsx # ❌ 在这里调用无效,因为在语言路由之外
在完成了上述的配置后,我们可以在 generateMetadata
函数的参数中得到 locale
,在此函数内部使用 getTranslations
函数获取翻译功能的 t
函数。
export async function generateMetadata({ params }: {
params: Promise<{
locale: string
}>
}) {
const { locale } = await params
const t = await getTranslations({ locale, namespace: 'Metadata' });
return {
title: t('title'),
description: t('description'),
keywords: t('keywords'),
};
}
如此一来便可以在不同语言录音下的页面添加多语言的 metadata 信息了。
next-intl
仅需要我们在根目录创建一个global.d.ts
文件,声明类型即可:
import en from "./messages/en.json";
type Messages = typeof en;
declare global {
// Use type safe message keys with `next-intl`
type IntlMessages = Messages;
}
后续使用t()
的时候编辑器就能提供候选补全了。
首先,我们创建一个 SiteHeader
组件:
import { useLocale, useTranslations } from "next-intl";
import Link from "next/link";
import { PiGoogleChromeLogoDuotone } from "react-icons/pi";
import { routing } from '../../../i18n/routing';
import LanguageSwitch from "./LanguageSwitch";
export default function SiteHeader() {
const t = useTranslations('HomePage');
const locale = useLocale();
return (
<div className="mx-auto">
<div className='flex items-center gap-4 2xl:w-[1360px] p-4'>
{/* add website header here */}
<Link href={'/'} locale={locale} className="flex items-center gap-1">
<PiGoogleChromeLogoDuotone className=' text-xl 2xl:text-3xl text-blue-600' />
<h1 className="mr-auto">{t('title')}</h1>
</Link>
<div className="mr-auto">
{/* add other nav */}
</div>
{/* select to switch language */}
<LanguageSwitch locales={routing.locales as unknown as string[]} />
</div>
</div>
)
}
以及一个核心切换的客户端组件:
"use client"
import clsx from "clsx";
import { useLocale, useTranslations } from "next-intl";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LiaLanguageSolid } from "react-icons/lia";
export default function LanguageSwitch({ locales }: { locales: string[] }) {
const pathname = usePathname()
const locale = useLocale()
const t = useTranslations()
return (
<div className="relative group">
<div className="flex items-center gap-1 bg-blue-500 rounded-full px-2 p-0.5 text-white">
<LiaLanguageSolid /> {t('Common.language')}
</div>
<div className="absolute top-[100%] flex-col bg-gray-50/80 inset-x-0 rounded-md p-1 gap-1 border hidden group-hover:flex">
{locales.map((lang) => (
<Link className={clsx(lang === locale ? 'text-blue-600 bg-white' : 'text-gray-600', 'text-sm px-1 hover:bg-white')} key={lang} href={pathname.replace(/^\/[^/]+/, `/${lang}`)} locale={lang}>
{t(`Locales.${lang}`)}
</Link>
))}
</div>
</div>
)
}
最后的效果如下,在鼠标移动到上面时通过 tailwindcss
控制切换标签的显示和隐藏,语言数组从配置文件中导出,再使用 Link
组件配合 locale
属性就能在点击的时候切换到不同的语言路由下了:
好了,今天分享就到这里。
我预计需要一些时间完成这个网站,我打算把自己开发的所有浏览器插件全部放到这上面,再加上用户体系控制,分享一些我开发的免费的简单插件和部分预计可以收费的核心插件。
为了记录这个过程,我会把遇到的问题和解决方案都发布在公众号上,欢迎阅读和分享。
再会。