エッセイ
在 React Router v7 中集成 typesafe-i18n#
构建多语言应用时,类型安全经常是事后才补上的问题。很多 i18n 方案依赖字符串 key,例如:
t("home.welcome")
这种方式简单,但也容易出现:
- key 拼错,运行时才发现。
- 参数漏传或类型不对。
- 某个 locale 缺少翻译。
- IDE 自动补全不够好。
- 重构翻译结构时缺少编译期反馈。
typesafe-i18n 的思路不同:它通过生成器根据 base locale 生成类型化的翻译函数。你最终调用的是:
LL.home.welcome()
如果 key 不存在,TypeScript 会直接报错。
这篇文章介绍如何把 typesafe-i18n 集成到 React Router v7 Framework Mode SSR 项目中,并处理几个 SSR 场景下容易踩坑的点:locale 检测、异步加载、clientLoader.hydrate、HydrateFallback 和语言前缀路由。
先说适用性#
typesafe-i18n 适合:
- 你非常重视翻译 key 和参数的类型安全。
- 翻译内容主要在代码仓库中维护。
- 你希望运行时足够轻量。
- 你的项目使用 TypeScript。
- 你愿意把“生成类型”纳入开发流程。
它不一定适合:
- 翻译主要由非技术团队在 CMS/TMS 中维护。
- 你需要非常成熟的企业级 i18n 生态。
- 你已经深度依赖 i18next、FormatJS、Lingui 等方案。
- 你不想维护生成文件和类型生成脚本。
截至本文更新时,typesafe-i18n 的仓库已在 codingcommons/typesafe-i18n 下维护;README 仍然强调 fully type-safe、轻量、支持 SSR、异步 locale 加载、plural rules、formatter、无外部依赖等特性。
为什么选择 typesafe-i18n#
和传统字符串 key 方案相比,它的最大优势是类型安全。
// ❌ 传统方式:拼错 key 可能运行时才发现
t("home.welcme")
// ✅ typesafe-i18n:key 不存在会编译报错
LL.home.welcome()
带参数的翻译也能类型检查:
const en = {
greeting: "Hello, {name:string}!",
items: "{count:number} {count|item}",
} satisfies BaseTranslation;
调用时:
LL.greeting({ name: "Asuka" });
LL.items({ count: 5 });
// ❌ name 必须是 string
LL.greeting({ name: 123 });
// ❌ 缺少 name
LL.greeting({});
这对长期维护多语言页面很有价值。
安装与初始化#
安装:
pnpm add typesafe-i18n
你可以使用官方 setup 命令生成初始结构:
pnpm exec typesafe-i18n --setup
也可以手动创建配置文件。
// .typesafe-i18n.json
{
"$schema": "https://unpkg.com/typesafe-i18n/schema/typesafe-i18n.json",
"baseLocale": "en",
"outputPath": "./app/i18n/"
}
注意:原来很多示例会把 schema URL 固定到某个版本,例如 @5.26.2。这能保证可重复,但文章里不建议硬编码旧版本。实际项目中可以:
- 固定 schema 版本,追求稳定。
- 或使用不带版本的 schema,跟随当前包版本。
二者都可以,关键是团队要明确版本升级策略。
创建翻译文件#
英文 base locale:
// app/i18n/en/index.ts
import type { BaseTranslation } from "../i18n-types";
const en = {
locale: "English",
nav: {
home: "Home",
about: "About",
search: "Search",
},
home: {
welcome: "Welcome to my blog",
noPostsYet: "No posts yet. Stay tuned!",
},
common: {
loading: "Loading...",
},
} satisfies BaseTranslation;
export default en;
中文翻译:
// app/i18n/zh/index.ts
import type { Translation } from "../i18n-types";
const zh = {
locale: "中文",
nav: {
home: "首页",
about: "关于",
search: "搜索",
},
home: {
welcome: "欢迎来到我的博客",
noPostsYet: "暂无文章,敬请期待!",
},
common: {
loading: "加载中...",
},
} satisfies Translation;
export default zh;
然后运行生成器:
pnpm exec typesafe-i18n --no-watch
常见生成文件包括:
app/i18n/
├── en/index.ts
├── zh/index.ts
├── formatters.ts
├── i18n-types.ts
├── i18n-util.ts
└── i18n-util.async.ts
如果你新增了翻译 key,却发现 LL.xxx 类型不存在,通常是忘了重新运行 generator。
建议加到 package scripts#
为了避免忘记生成类型,建议加入脚本:
{
"scripts": {
"i18n": "typesafe-i18n --no-watch",
"i18n:watch": "typesafe-i18n",
"typecheck": "pnpm i18n && react-router typegen && tsc"
}
}
在 CI 中也应该跑一次生成和 typecheck。否则本地新增翻译后没有提交生成文件,线上构建可能才失败。
创建 I18n Provider#
在 React 中使用时,可以自己封装一个 Provider。
// app/components/i18n-provider.tsx
import {
createContext,
type PropsWithChildren,
useContext,
useMemo,
} from "react";
import type { Locales, TranslationFunctions } from "~/i18n/i18n-types";
import { i18nObject } from "~/i18n/i18n-util";
interface I18nContextValue {
locale: Locales;
LL: TranslationFunctions;
}
const I18nContext = createContext<I18nContextValue | null>(null);
export function I18nProvider({
locale,
children,
}: PropsWithChildren<{ locale: Locales }>) {
const value = useMemo<I18nContextValue>(
() => ({
locale,
LL: i18nObject(locale),
}),
[locale],
);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) {
throw new Error("useI18n must be used within an I18nProvider");
}
return context;
}
export function useTranslation() {
return useI18n().LL;
}
export function useLocale() {
return useI18n().locale;
}
这里用 i18nObject(locale) 从已加载的 locale 创建翻译函数。
关键点是:在调用 LL.xxx() 之前,必须确保对应 locale 已经被 loadLocaleAsync(locale) 或 loadAllLocales() 加载。 typesafe-i18n README 也明确提到,如果 LL.key() 渲染为空字符串,常见原因就是忘了先加载 locale。
配置语言路由#
React Router v7 Framework Mode 推荐用 routes.ts 定义路由。
如果你的应用希望 URL 带语言前缀,例如:
/en
/zh
/en/about
/zh/about
可以用 prefix 包一层:
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { prefix } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default [
...prefix(":lang", await flatRoutes()),
] satisfies RouteConfig;
注意两点:
flatRoutes()是异步的,需要await。prefix()只是给 path 加前缀,不会自动创建一个 layout route。
如果你需要一个统一的 locale layout,更推荐显式定义:
// app/routes.ts
import type { RouteConfig } from "@react-router/dev/routes";
import { route } from "@react-router/dev/routes";
export default [
route(":lang", "routes/$lang.tsx", [
route("", "routes/$lang._index.tsx", { index: true }),
route("about", "routes/$lang.about.tsx"),
]),
] satisfies RouteConfig;
实际项目中可以按文件路由和手写路由的偏好选择。重点是要有一个稳定的 layout route 负责加载 locale 和挂载 I18nProvider。
Locale 辅助函数#
// app/lib/locale.ts
import type { Locales } from "~/i18n/i18n-types";
import { baseLocale, isLocale } from "~/i18n/i18n-util";
export function getLocaleFromParams(
params: Record<string, string | undefined>,
): Locales {
const lang = params.lang;
if (lang && isLocale(lang)) {
return lang;
}
return baseLocale;
}
export function localePath(locale: Locales, path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
return `/${locale}${normalizedPath}`;
}
真实项目还可以继续扩展:
- 从 cookie 读取用户偏好。
- 从
Accept-Language选择默认语言。 - 对非法 locale 做 redirect。
- 对 canonical URL 做 SEO 处理。
- 输出
hreflang。
在 layout loader 中加载 locale#
React Router 文档说明:loader 在服务端渲染时用于初始页面加载,clientLoader 可用于客户端数据加载;loader 和 clientLoader 也可以同时使用,clientLoader.hydrate = true 可以强制 client loader 在 hydration 期间、页面渲染前运行。
对于 i18n,最重要的是:服务端和客户端 hydration 前都要能拿到同一个 locale 的翻译函数。
// app/routes/$lang.tsx
import { Outlet } from "react-router";
import { I18nProvider } from "~/components/i18n-provider";
import { loadLocaleAsync } from "~/i18n/i18n-util.async";
import { getLocaleFromParams } from "~/lib/locale";
import type { Route } from "./+types/$lang";
export const loader = async ({ params }: Route.LoaderArgs) => {
const locale = getLocaleFromParams(params);
await loadLocaleAsync(locale);
return { locale };
};
export const clientLoader = async ({
params,
serverLoader,
}: Route.ClientLoaderArgs) => {
const serverData = await serverLoader<typeof loader>();
const locale = getLocaleFromParams(params);
await loadLocaleAsync(locale);
return serverData;
};
clientLoader.hydrate = true as const;
export function HydrateFallback() {
return null;
}
export default function LocaleLayout({ loaderData }: Route.ComponentProps) {
const { locale } = loaderData;
return (
<I18nProvider locale={locale}>
<Outlet />
</I18nProvider>
);
}
这里有几个关键点:
loader负责 SSR 时加载 locale。clientLoader负责 hydration 前在客户端也加载 locale。serverLoader()复用服务端 loader 数据,避免重新实现一套数据逻辑。clientLoader.hydrate = true让 client loader 在 hydration 阶段也执行。HydrateFallback可以返回null,避免 hydration 前显示一段和服务端 HTML 不一致的 loading UI。
React Router 官方文档也提醒:当你强制 client loader 在 hydration 期间运行时,应该提供 HydrateFallback 用来显示 fallback UI。对于 i18n 场景,如果服务端 HTML 已经是完整翻译内容,返回 null 往往比显示 loading 更不容易造成闪烁。
为什么不能只在 loader 里加载#
很多人会问:既然 SSR 时 loader 已经 loadLocaleAsync(locale) 了,为什么客户端还要再加载?
原因是服务端和客户端是两个运行环境。
服务端加载了 locale,只能保证服务端渲染 HTML 时可用;浏览器 hydration 时也需要自己的 JS 模块和翻译函数。如果客户端没有加载对应 locale,调用 LL.home.welcome() 可能得到空字符串或不完整结果。
所以 SSR i18n 的基本原则是:
server render 前加载 locale
+
client hydrate 前加载同一个 locale
否则就容易出现:
- 服务端 HTML 是中文,客户端 hydration 后变成空字符串。
- 服务端和客户端文本不一致,引发 hydration mismatch。
- 首屏闪一下默认语言再切换到目标语言。
在组件中使用翻译#
// app/routes/$lang._index.tsx
import { useI18n } from "~/components/i18n-provider";
export default function HomePage() {
const { LL } = useI18n();
return (
<main>
<h1>{LL.home.welcome()}</h1>
<p>{LL.home.noPostsYet()}</p>
</main>
);
}
自动补全会类似:
LL.
├── locale()
├── nav.
│ ├── home()
│ ├── about()
│ └── search()
├── home.
│ ├── welcome()
│ └── noPostsYet()
└── common.
└── loading()
语言切换器#
// app/components/locale-switcher.tsx
import { useLocation, useNavigate } from "react-router";
import type { Locales } from "~/i18n/i18n-types";
import { locales } from "~/i18n/i18n-util";
import { useLocale } from "./i18n-provider";
const LABELS: Record<Locales, string> = {
en: "English",
zh: "中文",
};
export function LocaleSwitcher() {
const currentLocale = useLocale();
const navigate = useNavigate();
const location = useLocation();
const switchLocale = (newLocale: Locales) => {
const newPath = location.pathname.replace(
new RegExp(`^/${currentLocale}(/|$)`),
`/${newLocale}$1`,
);
navigate(newPath + location.search + location.hash);
};
return (
<select
value={currentLocale}
onChange={(event) => switchLocale(event.target.value as Locales)}
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{LABELS[locale]}
</option>
))}
</select>
);
}
实际项目里要注意:
- 当前路径不一定有 locale 前缀。
- 切换语言时可能需要保留 query 和 hash。
- 不同语言可能不是同一套 slug。
- 文章、商品、文档等内容型页面可能需要 locale-aware slug mapping。
SEO 和 HTML lang#
多语言 SSR 项目还应该设置 HTML lang。
在 React Router 的 root.tsx 或 locale layout 中,可以把 locale 放进 loader data,然后用于 <html lang={locale}>。
如果项目需要搜索引擎优化,还应该输出:
- canonical URL。
- alternate hreflang。
- 当前页面的 localized title / description。
- fallback locale 策略。
这部分和 typesafe-i18n 本身无关,但和 SSR i18n 密切相关。
常见问题#
1. LL.xxx() 渲染为空字符串#
通常是忘了先加载 locale。
检查:
await loadLocaleAsync(locale);
是否在服务端 loader 和客户端 hydration 前都执行了。
2. 新增翻译 key 后类型不存在#
通常是忘了重新运行 generator。
pnpm exec typesafe-i18n --no-watch
3. hydration mismatch#
常见原因:
- 服务端和客户端 locale 不一致。
- 客户端 hydration 前没有加载 locale。
HydrateFallback显示了和 SSR HTML 不同的内容。- locale 来自 cookie / header / URL,但客户端计算方式不同。
4. prefix(':lang', await flatRoutes()) 没有 layout#
这是正常的。prefix 只改 path,不创建新的 layout route。
如果需要统一加载 locale,显式创建 :lang layout route 更清晰。
小结#
在 React Router v7 SSR 应用中集成 typesafe-i18n,关键不是 Provider 写法,而是加载时机。
你需要保证:
- base locale 定义完整,其他 locale 使用
Translation检查结构。 - 每次修改翻译后运行 generator。
- SSR loader 中加载当前 locale。
- client hydration 前也加载同一个 locale。
clientLoader.hydrate = true时提供合适的HydrateFallback。- 语言前缀路由最好有一个明确的 locale layout。
- 语言切换、SEO、HTML lang、hreflang 需要单独设计。
typesafe-i18n 的价值在于把翻译 key、参数和 locale 结构错误提前暴露到编译期。对于 TypeScript 项目来说,这能显著降低多语言长期维护成本。