Next.js15 + i18n 初体验

2024年10月24上次更新于 24 天前
编程

我需要为我的 nextjs 项目添加 i18n 功能,从而更好地进行推广和服务。于是我尝试使用 next-intl库来完成这个功能,本文记录了我接入这个库的整个过程。

版本信息

  • nextjs 15
    • app 路由
    • 子域名方案
  • next-intl 3.23.4

起步

首先安装依赖 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来增强。

关键点:

  1. 通常在根布局(root layout)调用一次就足够了
  2. 如果页面需要在生成静态参数或元数据时使用翻译,那么该页面也需要调用
  3. 普通组件(包括服务端组件和客户端组件)不需要调用
  4. 这个函数的调用应该总是在使用任何翻译功能之前

举个例子:

app/
├── [locale]/           # 语言路由必须在这一层
│   ├── layout.tsx     # ✅ 在这里调用 setRequestLocale
│   ├── page.tsx
│   └── about/
│       └── page.tsx   # ❌ 不需要重复调用,已被 [locale]/layout.tsx 覆盖
│
└── layout.tsx         # ❌ 在这里调用无效,因为在语言路由之外

在 metadata 中使用多语言

在完成了上述的配置后,我们可以在 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 信息了。

启用 TypeScript 支持

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属性就能在点击的时候切换到不同的语言路由下了:

最后

好了,今天分享就到这里。

我预计需要一些时间完成这个网站,我打算把自己开发的所有浏览器插件全部放到这上面,再加上用户体系控制,分享一些我开发的免费的简单插件和部分预计可以收费的核心插件。

为了记录这个过程,我会把遇到的问题和解决方案都发布在公众号上,欢迎阅读和分享。

再会。

not-by-ainot-by-ai
文章推荐
avatar
Canvas 101
2024-08-08 updated.

Friends

Jimmy老胡SubmaraBruce SongScarsu