エッセイ

在 React Router v7 中集成 typesafe-i18n#

Asuka 著

构建多语言应用时,类型安全经常是事后才补上的问题。很多 i18n 方案依赖字符串 key,例如:

TSX
t("home.welcome")

这种方式简单,但也容易出现:

  • key 拼错,运行时才发现。
  • 参数漏传或类型不对。
  • 某个 locale 缺少翻译。
  • IDE 自动补全不够好。
  • 重构翻译结构时缺少编译期反馈。

typesafe-i18n 的思路不同:它通过生成器根据 base locale 生成类型化的翻译函数。你最终调用的是:

TSX
LL.home.welcome()

如果 key 不存在,TypeScript 会直接报错。

这篇文章介绍如何把 typesafe-i18n 集成到 React Router v7 Framework Mode SSR 项目中,并处理几个 SSR 场景下容易踩坑的点:locale 检测、异步加载、clientLoader.hydrateHydrateFallback 和语言前缀路由。

先说适用性#

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 方案相比,它的最大优势是类型安全。

TSX
// ❌ 传统方式:拼错 key 可能运行时才发现
t("home.welcme")

// ✅ typesafe-i18n:key 不存在会编译报错
LL.home.welcome()

带参数的翻译也能类型检查:

TS
const en = {
  greeting: "Hello, {name:string}!",
  items: "{count:number} {count|item}",
} satisfies BaseTranslation;

调用时:

TSX
LL.greeting({ name: "Asuka" });
LL.items({ count: 5 });

// ❌ name 必须是 string
LL.greeting({ name: 123 });

// ❌ 缺少 name
LL.greeting({});

这对长期维护多语言页面很有价值。

安装与初始化#

安装:

Bash
pnpm add typesafe-i18n

你可以使用官方 setup 命令生成初始结构:

Bash
pnpm exec typesafe-i18n --setup

也可以手动创建配置文件。

JSON
// .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:

TS
// 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;

中文翻译:

TS
// 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;

然后运行生成器:

Bash
pnpm exec typesafe-i18n --no-watch

常见生成文件包括:

TEXT
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#

为了避免忘记生成类型,建议加入脚本:

JSON
{
  "scripts": {
    "i18n": "typesafe-i18n --no-watch",
    "i18n:watch": "typesafe-i18n",
    "typecheck": "pnpm i18n && react-router typegen && tsc"
  }
}

在 CI 中也应该跑一次生成和 typecheck。否则本地新增翻译后没有提交生成文件,线上构建可能才失败。

创建 I18n Provider#

在 React 中使用时,可以自己封装一个 Provider。

TSX
// 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 带语言前缀,例如:

TEXT
/en
/zh
/en/about
/zh/about

可以用 prefix 包一层:

TS
// 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;

注意两点:

  1. flatRoutes() 是异步的,需要 await
  2. prefix() 只是给 path 加前缀,不会自动创建一个 layout route。

如果你需要一个统一的 locale layout,更推荐显式定义:

TS
// 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 辅助函数#

TS
// 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 可用于客户端数据加载;loaderclientLoader 也可以同时使用,clientLoader.hydrate = true 可以强制 client loader 在 hydration 期间、页面渲染前运行。

对于 i18n,最重要的是:服务端和客户端 hydration 前都要能拿到同一个 locale 的翻译函数。

TSX
// 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>
  );
}

这里有几个关键点:

  1. loader 负责 SSR 时加载 locale。
  2. clientLoader 负责 hydration 前在客户端也加载 locale。
  3. serverLoader() 复用服务端 loader 数据,避免重新实现一套数据逻辑。
  4. clientLoader.hydrate = true 让 client loader 在 hydration 阶段也执行。
  5. 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 的基本原则是:

TEXT
server render 前加载 locale
+
client hydrate 前加载同一个 locale

否则就容易出现:

  • 服务端 HTML 是中文,客户端 hydration 后变成空字符串。
  • 服务端和客户端文本不一致,引发 hydration mismatch。
  • 首屏闪一下默认语言再切换到目标语言。

在组件中使用翻译#

TSX
// 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>
  );
}

自动补全会类似:

TEXT
LL.
  ├── locale()
  ├── nav.
  │   ├── home()
  │   ├── about()
  │   └── search()
  ├── home.
  │   ├── welcome()
  │   └── noPostsYet()
  └── common.
      └── loading()

语言切换器#

TSX
// 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。

检查:

TS
await loadLocaleAsync(locale);

是否在服务端 loader 和客户端 hydration 前都执行了。

2. 新增翻译 key 后类型不存在#

通常是忘了重新运行 generator。

Bash
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 写法,而是加载时机。

你需要保证:

  1. base locale 定义完整,其他 locale 使用 Translation 检查结构。
  2. 每次修改翻译后运行 generator。
  3. SSR loader 中加载当前 locale。
  4. client hydration 前也加载同一个 locale。
  5. clientLoader.hydrate = true 时提供合适的 HydrateFallback
  6. 语言前缀路由最好有一个明确的 locale layout。
  7. 语言切换、SEO、HTML lang、hreflang 需要单独设计。

typesafe-i18n 的价值在于把翻译 key、参数和 locale 结构错误提前暴露到编译期。对于 TypeScript 项目来说,这能显著降低多语言长期维护成本。

参考资料#

This browser prefers English.

You can switch the site to English and save that choice for future visits.