如何在 TanStack Start v1 中从文件加载翻译

将可翻译内容与代码分离

问题

将面向用户的字符串直接硬编码到组件中,会导致内容与代码之间的紧密耦合。每次字符串发生更改或添加新语言时,开发人员都必须定位并修改源文件,然后重新部署应用程序。这种方法使得翻译工作流依赖于工程周期,并阻止非技术团队成员更新文案。随着支持语言数量的增加,用于选择正确字符串的条件逻辑变得难以管理且容易出错。其结果是迭代速度变慢、维护成本增加,以及代码库中充斥着本应存放在其他地方的可翻译内容。

解决方案

通过将所有可翻译字符串存储在外部 JSON 文件中(每个语言环境一个文件),将它们与应用程序代码分离。为每条消息定义一个稳定的键,并在组件中引用这些键,而不是直接使用文本。在运行时,根据用户的语言环境加载相应的翻译文件,并将这些消息提供给 react-intl 的 IntlProvider。这将内容与代码解耦:翻译人员可以直接处理 JSON 文件,文案更改无需修改代码,添加新语言只需添加一个新文件,而无需修改组件。

步骤

1. 为每个语言环境创建翻译文件

将翻译内容组织为 JSON 文件,存放在专用目录中,每个语言环境一个文件,包含所有消息的键值对。

{
"welcome": "欢迎回来",
"greeting": "你好,{name}",
"itemCount": "{count, plural, =0 {没有项目} one {一个项目} other {# 个项目}}"
}

将此文件保存为 app/translations/zh.json。为其他语言环境创建对应的文件,例如 app/translations/es.jsonapp/translations/fr.json,使用相同的键但翻译后的值。

2. 使用服务器函数加载翻译

使用服务器函数根据请求的语言环境从磁盘读取翻译文件,确保在 SSR(服务器端渲染)期间加载翻译,并在导航时在客户端获取。

import { createServerFn } from "@tanstack/react-start";
import * as fs from "node:fs";

export const getMessages = createServerFn({ method: "GET" }).handler(
  async ({ request }) => {
    const url = new URL(request.url);
    const locale = url.searchParams.get("locale") || "en";
    const filePath = `app/translations/${locale}.json`;
    const content = await fs.promises.readFile(filePath, "utf-8");
    return JSON.parse(content);
  },
);

此函数读取指定语言环境的 JSON 文件并返回解析后的消息对象。它仅在服务器上运行,确保文件系统访问的安全性。

3. 创建一个帮助函数以确定用户的语言环境

定义一个小型实用工具,从请求中提取语言环境或回退到默认值,使其可以在各个路由中重复使用。

export function getLocaleFromRequest(request: Request): string {
  const url = new URL(request.url);
  const localeParam = url.searchParams.get("locale");
  if (localeParam) return localeParam;
  const acceptLanguage = request.headers.get("accept-language");
  if (acceptLanguage) {
    const match = acceptLanguage.split(",")[0].split("-")[0];
    return match || "en";
  }
  return "en";
}

此函数首先检查查询参数,然后检查 Accept-Language 头部,最后默认为英语。它为语言环境检测提供了单一的可信来源。

4. 在路由加载器中加载消息

使用路由加载器在渲染之前为当前语言环境获取消息,使其可用于组件树。

import { createFileRoute } from "@tanstack/react-router";
import { getMessages, getLocaleFromRequest } from "../lib/i18n";

export const Route = createFileRoute("/")({
  loader: async ({ context }) => {
    const locale = getLocaleFromRequest(context.request);
    const messages = await getMessages({ data: { locale } });
    return { locale, messages };
  },
  component: HomePage,
});

function HomePage() {
  const { locale, messages } = Route.useLoaderData();
  return (
    <div>
      <p>{messages.welcome}</p>
    </div>
  );
}

加载器调用服务器函数以检索消息,组件通过 useLoaderData 访问它们。此模式适用于 SSR 和客户端导航。

5. 提供消息给 react-intl

使用 IntlProvider 包裹您的组件树,传递加载的语言环境和消息,这样所有子组件都可以访问翻译内容。

import { IntlProvider } from "react-intl";

function HomePage() {
  const { locale, messages } = Route.useLoaderData();
  return (
    <IntlProvider locale={locale} messages={messages}>
      <AppContent />
    </IntlProvider>
  );
}

function AppContent() {
  return (
    <div>
      <FormattedMessage id="welcome" />
    </div>
  );
}

IntlProvider 将语言环境和消息提供给所有 react-intl 组件和钩子。组件现在可以通过 FormattedMessageuseIntl 引用消息的键,并根据加载的语言环境渲染正确的翻译。

6. 在组件中通过键引用消息

使用 react-intl 的 FormattedMessage 组件或 useIntl 钩子来显示翻译字符串,引用 JSON 文件中定义的键。

import { FormattedMessage, useIntl } from "react-intl";

function UserGreeting({ name }: { name: string }) {
  const intl = useIntl();
  const title = intl.formatMessage({ id: "greeting" }, { name });
  return (
    <div>
      <h1 title={title}>
        <FormattedMessage id="greeting" values={{ name }} />
      </h1>
      <p>
        <FormattedMessage id="itemCount" values={{ count: 5 }} />
      </p>
    </div>
  );
}

FormattedMessage 会内联渲染翻译字符串,而 useIntl().formatMessage 返回一个字符串,可用于属性或 JavaScript 逻辑。两者都接受 values 用于插值,并支持 ICU 消息语法以处理复数和格式化。