如何在 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.json 和 app/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。组件现在可以通过 FormattedMessage 或 useIntl 按 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 消息语法的复数和格式化。