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

将可翻译内容与代码分离

问题

将面向用户的字符串直接硬编码到组件中,会导致内容与代码紧密耦合。每当字符串发生变更或需要新增语言时,开发者都必须定位并修改源文件,然后重新部署应用。这种方式让翻译流程依赖于开发周期,阻碍了非技术团队成员对文案的更新。随着支持的语言数量增加,选择正确字符串的条件逻辑会变得复杂且容易出错。最终结果是迭代速度变慢、维护成本上升,代码库中充斥着本应独立管理的可翻译内容。

解决方案

将所有可翻译字符串从应用代码中分离,存储在外部 JSON 文件中,每个语言环境一个文件。为每条消息定义一个稳定的 key,在组件中引用这些 key,而不是直接写文本。在运行时,根据用户的语言环境加载对应的翻译文件,并将这些消息提供给 react-intl 的 IntlProvider。这样内容与代码彻底解耦:译者可以直接编辑 JSON 文件,文案变更无需修改代码,新增语言只需添加新文件,无需改动组件。

步骤

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

将翻译内容以 JSON 文件的形式组织在专用目录下,每个语言环境一个文件,文件中包含所有消息的 key-value 对。

{
"welcome": "Welcome back",
"greeting": "Hello, {name}",
"itemCount": "{count, plural, =0 {No items} one {One item} other {# items}}"
}

将此文件保存为 app/translations/en.json。为其他语言环境创建对应的平行文件,如 app/translations/es.jsonapp/translations/fr.json,key 保持一致,value 为翻译后的内容。

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

使用服务器函数根据请求的 locale 从磁盘读取翻译文件,确保在 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);
  },
);

此函数会读取指定 locale 的 JSON 文件并返回解析后的 messages 对象。该函数仅在服务器端运行,保证文件系统访问的安全性。

3. 创建辅助工具以确定用户的 locale

定义一个小型工具,从请求中提取 locale,或回退到默认值,便于在各路由间复用。

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 头部,最后默认为英文。它为 locale 检测提供了单一可信来源。

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

使用路由加载器在渲染前获取当前 locale 的消息,使其可用于组件树。

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 包裹组件树,传递已加载的 locale 和消息,使所有子组件都能访问翻译内容。

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 会将 locale 和消息提供给所有 react-intl 组件和 hooks。组件现在可以通过 FormattedMessageuseIntl 按 key 引用消息,并根据已加载的 locale 渲染正确的翻译内容。

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

使用 react-intl 的 FormattedMessage 组件或 useIntl hook 来显示已翻译的字符串,引用你在 JSON 文件中定义的 key。

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 消息语法的复数和格式化。