Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Next.js15 + i18n 初体验 #89

Open
AaronConlon opened this issue Oct 24, 2024 · 0 comments
Open

Next.js15 + i18n 初体验 #89

AaronConlon opened this issue Oct 24, 2024 · 0 comments
Labels
编程 软件开发的旅途

Comments

@AaronConlon
Copy link
Owner

AaronConlon commented Oct 24, 2024


description: 我需要为我的 nextjs 项目添加 i18n 功能,从而更好地进行推广和服务。于是我尝试使用next-intl库来完成这个功能,本文记录了我接入这个库的整个过程。
cover: https://de4965e.webp.li/blog-images/2024/10/bed7636024f5a408164690e71b83e63f.jpg

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

最后

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

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

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

再会。

@AaronConlon AaronConlon added the 编程 软件开发的旅途 label Oct 24, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
编程 软件开发的旅途
Projects
None yet
Development

No branches or pull requests

1 participant