在博客中添加多语言功能(NextJS、next-intl、Vercel AI SDK)

힘센캥거루
2025년 11월 25일
3
nextjs

最近我觉得博客需要多语言功能。

于是决定用 next-intl 来实现多语言服务。

上传的图片

1.i18n

首先,在做多语言服务时有一些必须遵守的原则。

这被称为 internationalization,单词太长,所以把首字母 i 和尾字母 n,以及中间 18 个字母合在一起,写成 i18n。

1) i18n 的原则

(1) 禁止字符串硬编码

不要在代码里直接写入像「회원가입」这样的字符串,而是始终通过消息键来获取。

比如我们要做一个博客首页。

可以像下面这样写。

// components/Header.tsx
export function Header() {
  return (
    <header>
      <h1>지구과학 블로그</h1>
      <button>로그인</button>
      <button>회원가입</button>
    </header>
  );
}

如果硬编码的话,就必须为每种语言单独创建一个组件。

与其这样,不如按照语言、地区拆分资源,这样就可以集中修改。

比如韩文是 ko.ts,英文是 en.ts,分别准备独立的资源文件。

当然,使用 json 也完全没问题。

// locales/ko.ts
export const ko = {
  header: {
    title: '지구과학 블로그',
    login: '로그인',
    signup: '회원가입',
  },
};

// locales/en.ts
export const en = {
  header: {
    title: 'Earth Science Blog',
    login: 'Log in',
    signup: 'Sign up',
  },
};

根据 locale 调用对应内容,就可以实现切换语言。

(2) 使用参数与支持复数形式

"안녕하세요 힘캥님" 这样的内容,要以 `안녕하세요, ${name}님` 的形式管理,只让名字部分发生变化。

另外,英语中 1 comment、2 comments 有单复数区分,这种内容需要在消息层面做好处理。

// locales/ko.ts
export const ko = {
  greeting: '안녕하세요, {name}님',
  comments: {
    zero: '아직 댓글이 없습니다.',
    one: '댓글이 1개 있습니다.',
    other: '댓글이 {count}개 있습니다.',
  },
};

// locales/en.ts
export const en = {
  greeting: 'Hello, {name}',
  comments: {
    zero: 'No comments yet.',
    one: 'There is 1 comment.',
    other: 'There are {count} comments.',
  },
};

(3) 按 locale 处理日期 / 数字 / 货币格式

不同国家在表示日期和数字方面差异很大。

比如 2025 年 11 月 27 日,可以写成 2025-11-27,也可以写成 27/11/2025

(4) locale negotiation 与默认值(fallback)

先查看浏览器语言、URL、Cookie 等,如果没有支持的语言,则设置为 defaultLocale。

(5) 翻译与代码的分离

开发者不负责翻译文本,只使用键。

译者通过 JSON 或单独的工具来管理文本。

2. next-intl

在众多库中,next-intl 在满足以上 i18n 条件 的同时,也有大量用户在使用。

所以我也决定在自己的博客上试着使用它。

先安装库。

yarn add next-intl

然后在根目录下创建 i18n/config.ts 文件。

// i18n/config.ts
export const defaultLocale = 'ko';
export const locales = ['ko', 'en', 'ja'] as const;
export type Locale = (typeof locales)[number];

接着在 i18n/request.ts 中创建一个用于在服务器组件中获取消息的函数。

// i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { defaultLocale } from './config';

export default getRequestConfig(async ({ requestLocale }: { requestLocale: Promise<string | undefined> }) => {
  const locale = await requestLocale || defaultLocale;
  return {
    locale,
    messages: (await import(`./message/${locale}.json`)).default,
  };
});

然后创建一个 message 文件夹,为每个语言编写一个 json 文件。

如果缺少某个语言对应的 json 文件,访问时就会报错。

/messages
  ├── ko.json
  ├── en.json
  └── ja.json

这些文件必须具有相同的内部结构。

如果你想折磨开发者,就故意做成不同结构吧。

// ko.json
{
  "Home": {
    "title": "지구과학 블로그",
    "readMore": "더보기"
  }
}

// en.json
{
  "Home": {
    "title": "Earth Science Blog",
    "readMore": "Read more"
  }
}

接下来轮到创建 [locale] 文件夹。

在 App Router 中,把 locale 放在顶层路径是比较自然的做法。

不需要 locale 的路径或 api 可以排除在外。

app
 ├── [locale]
 │     ├── layout.tsx
 │     ├── page.tsx
 │     └── ... (其他路由)
 └── layout.tsx

在主 layout.tsx 中按需要进行配置即可。

[locale]/layout.tsx 中如下设置,以便能够使用 locale。

// app/[locale]/layout.tsx
import { NextIntlClientProvider, getMessages } from 'next-intl';
import { locales } from '@/i18n/config';
import { notFound } from 'next/navigation';

export default async function LocaleLayout({ children, params }) {
  const { locale } = params;

  if (!locales.includes(locale)) {
    notFound(); // 404 处理
  }

  const messages = await getMessages();

  return (
    <html lang={locale}>
      <body>
        <NextIntlClientProvider messages={messages} locale={locale}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

这里的 getMessages() 函数会根据 locale 获取消息,然后通过 Provider 分发。

这样在子组件中就可以根据 locale 使用对应的消息。

来看一个使用示例。

// 服务器组件
import { getTranslations } from 'next-intl/server';

export default async function Page() {
  const t = await getTranslations('Home');

  return <h1>{t('title')}</h1>;
}

// 客户端组件
'use client';

import { useTranslations } from 'next-intl';

export default function ReadMoreButton() {
  const t = useTranslations('Home');

  return <button>{t('readMore')}</button>;
}

在服务器组件中用 getTranslation,在客户端组件中用 useTranslation。

在 metadata 中也可以用同样方式根据 locale 设置不同的值。

// app/[locale]/posts/[slug]/page.tsx
import { getTranslations } from 'next-intl/server';

export async function generateMetadata({ params }) {
  const t = await getTranslations({ locale: params.locale, namespace: 'Post' });

  return {
    title: t('metaTitle'),
    description: t('metaDescription')
  };
}

2. 修改 db schema

到目前为止难度都不算高。

直到我意识到必须修改 db 为止。

我之前使用的 db schema 如下。

model Post {
  id               String          @id @default(cuid())
  title            String
  processedContent String?         // 用于渲染的处理后内容
  thumbnail        String?
  createdAt        DateTime        @default(now())
  updatedAt        DateTime?
  likes            Int             @default(0)
  categoryId       String?
  ....
}

如果想让所有 locale 共享 likes、views,就必须把 processedContent 拆出来,再建一张按 locale 区分的表。

如果在 post 本身里塞入所有 locale 的 content,表会变得过于臃肿,也不符合范式。

修改 schema 本身并不难,但一旦把内容从 post 中拆出去,我站点里所有写文章、查询、分页等函数都必须一起改。

所以我决定先逐步改造。

// 保留原有 Post
model Post {
  ....
}

model PostLocale {
  id               String   @id @default(cuid())
  postId           String
  locale           String
  title            String
  excerpt          String
  content          String
  ...

  post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
}

两张表通过 relation 关联之后,就可以用 postId 和 locale 返回不同的文章内容。

为了尽量减少组件层的修改,我把原有的文章查询函数和通过 locale 查询文章的函数的输出类型做成一致。


export async function getLocalizedPostBySlug(
  slug: string,
  locale: string
): Promise<PostContentProps | null> {
  // 查询文章
  const basePost = await getPostBySlug(slug);
  // 根据查询到的文章内容,再查询对应 locale 的记录
  const localeEntry = await prisma.postLocale.findFirst({
    where: {
      postId: basePost.id as string,
      locale,
    },
  });

  const localizedContent = localeEntry.processedContent || localeEntry.content;
  // 返回查询结果
  return {
    ...basePost,
    title: localeEntry.title,
    excerpt: localeEntry.excerpt,
    category: localeEntry.category ?? basePost.category,
    content: localeEntry.content,
  };
}

在文章详情页的查询中也反映了这一逻辑。

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const locale = await getLocale();
  const post =
    locale !== "ko"
      ? await getLocalizedPostBySlug(slug, locale)
      : await getPostBySlug(slug);
  ...
}

现在只要把文章翻译好写进 db 就行了。

3. 使用 Vercel AI SDK 进行翻译

用 Python 也能实现,但我想用纯 TypeScript,于是决定使用 Vercel AI SDK。

使用 Generate Text 就可以通过 API 获取生成的内容。

import { PrismaClient } from "@prisma/client";
import { generateText } from "ai";
import { openai } from "@ai-sdk/openai";

const prisma = new PrismaClient();

const TARGET_LOCALES = ["en", "zh", "ja", "es"] as const;

async function translatePost(post: {
  id: string;
  title: string;
  content: string;
}) {
  ...
  // 查询文章
  for (const locale of TARGET_LOCALES) {
    const exists = await prisma.postLocale.findUnique({
      where: {
        postId_locale: {
          postId: post.id,
          locale,
        },
      },
    });
    // 预先指定输出格式
    const payload = JSON.stringify({
      targetLocale: locale,
      title: post.title,
      content: post.content,
    });

    const { text } = await generateText({
      model: openai("gpt-5.1"),
      // prompt 设置
      system:
        "You are a professional translator. Translate the JSON fields `title` and `content` into `targetLocale`. Preserve HTML tags, Markdown, code blocks, links, and inline styles exactly as-is; only translate the human-readable text. Keep technical terms and product names unless a well-known localized form exists. Return the translated JSON with the same keys and no extra commentary.",
      prompt: payload,
    });
    // text 里会有 ''' 之类的字符,实际使用时需要转义。
    const json = JSON.parse(text);

    await prisma.postLocale.create({
      data: {
        postId: post.id,
        locale,
        title: json.title || post.title,
        content: json.content || post.content,
        category: json.category || null,
        // 省略其他字段
      },
    });
    results[locale] = json;
  }
  return results;
}

像这样把大约 170 篇文章翻译成 4 种语言,大概花了一个多小时。

总共花费的 API 费用是 16.69 美元。

上传的图片

折合大约 24,000 韩元。

虽然用了 GPT-4o,但如果考虑翻译质量的话,这个价格还算可以接受。

4. 注册 sitemap.xml

最后要做的是修改 sitemap.xml。

原以为需要为每种语言单独生成 sitemap,结果发现通常只要在主域名的 sitemap 里把所有语言都列出来就行。

我大致以已发布的文章为基准来注册 sitemap。

...
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await prisma.post.findMany({
    where: { published: true },
    select: { id: true, slug: true, createdAt: true, updatedAt: true },
    orderBy: { updatedAt: "desc" },
  });

  const staticPages: MetadataRoute.Sitemap = locales.flatMap((locale) =>
    staticRoutes.map(({ path, changeFrequency, priority }) => ({
      url: buildLocalizedUrl(path, locale), // 我写的函数
      lastModified: new Date(),
      changeFrequency,
      priority,
    }))
  );
...

5. 后记

从决定做多语言服务到真正开始动手,中间纠结了 2 个月,但真正实现起来却不到 2~3 天。

果然任何事情都还是先做起来比较重要。

这次虽然只是通过多建一张表来大致解决问题,但我也意识到,一旦建好了 DB 并基于此开发了服务,之后再修改并不容易。

再次感受到 schema 和 DB 设计的重要性。

下次如果做别的项目,应该会先更加细致地检查 DB 设计。

댓글을 불러오는 중...