Essay

在 React Router v7 中集成 typesafe-i18n#

By Asuka

构建多语言应用时,类型安全往往是事后才考虑的问题。大多数 i18n 库依赖字符串键,比如 t('home.welcome'),这种方式容易出错且缺乏 IDE 支持。typesafe-i18n 采用了不同的方案——在构建时生成完全类型化的翻译函数。

这篇文章将介绍如何将 typesafe-i18n 集成到 React Router v7 SSR 项目中。

为什么选择 typesafe-i18n?#

在深入实现之前,先看看 typesafe-i18n 的优势:

特性 优势
完全类型化 翻译键自动补全,编译时错误检查
极小的运行时 gzip 后约 1KB,大部分工作在构建时完成
零依赖 没有外部依赖
框架无关 支持 React、Vue、Svelte 或原生 JS

开发体验对比:

TSX
// ❌ 传统方式 - 没有类型安全
t('home.welcme') // 拼写错误直到运行时才会发现

// ✅ typesafe-i18n 方式
LL.home.welcome() // 自动补全 + 键不存在时编译报错

项目配置#

从一个新的 React Router v7 项目开始。假设你已经有一个基本的 SSR 配置,目录结构如下:

Auto
app/
├── routes/
│   ├── _app.tsx        # 带导航的布局
│   └── _app._index.tsx # 首页
├── components/
├── root.tsx
└── routes.ts

第一步:安装 typesafe-i18n#

Bash
pnpm add typesafe-i18n

第二步:配置 typesafe-i18n#

在项目根目录创建配置文件:

JSON
// .typesafe-i18n.json
{
  "$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
  "baseLocale": "en",
  "outputPath": "./app/i18n/"
}

第三步:创建翻译文件#

创建英文基础翻译文件:

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

然后创建其他语言的翻译。这是中文版本:

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

第四步:生成类型#

运行 typesafe-i18n 生成器:

Bash
npx typesafe-i18n --no-watch

这会在 app/i18n/ 目录下创建几个文件:

  • i18n-types.ts - 生成的 TypeScript 类型
  • i18n-util.ts - 核心工具函数
  • i18n-util.async.ts - 异步加载函数
  • formatters.ts - 自定义格式化器(可选)

React Router 集成#

现在将其与 React Router v7 的 SSR 架构集成。

第五步:创建 I18n 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(
    () => ({
      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;
}

第六步:添加语言路由#

更新路由配置,添加语言前缀:

TypeScript
// 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';

async function resolveRoutes() {
  const routes = await flatRoutes();
  return prefix(':lang', routes);
}

export default resolveRoutes() satisfies Promise<RouteConfig>;

第七步:创建 Locale 辅助函数#

TypeScript
// 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}`;
}

第八步:更新布局路由#

这是 SSR 的关键部分。需要在服务端和客户端都加载翻译:

TSX
// app/routes/_app.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/_app';

// 服务端:加载翻译
export const loader = async ({ params }: Route.LoaderArgs) => {
  const locale = getLocaleFromParams(params);
  await loadLocaleAsync(locale);
  return { locale };
};

// 客户端:在 hydration 之前也要加载翻译
export const clientLoader = async ({
  params,
  serverLoader,
}: Route.ClientLoaderArgs) => {
  const serverData = await serverLoader();
  const locale = getLocaleFromParams(params);
  await loadLocaleAsync(locale);
  return serverData;
};

clientLoader.hydrate = true as const;

export function HydrateFallback() {
  return null;
}

export default function Layout({ loaderData }: Route.ComponentProps) {
  const { locale } = loaderData;

  return (
    <I18nProvider locale={locale}>
      <Outlet />
    </I18nProvider>
  );
}

关键点:

  1. loader 在服务端加载翻译
  2. clientLoader 在客户端 React hydration 之前加载翻译
  3. clientLoader.hydrate = true 确保客户端 loader 在 hydration 期间运行
  4. HydrateFallback 在过渡期间不显示任何内容,防止 hydration 不匹配

在组件中使用翻译#

现在可以在应用的任何地方使用完全类型化的翻译:

TSX
// app/routes/_app._index.tsx
import { useI18n } from '~/components/i18n-provider';

export default function HomePage() {
  const { LL } = useI18n();

  return (
    <div>
      <h1>{LL.home.welcome()}</h1>
      <p>{LL.home.noPostsYet()}</p>
    </div>
  );
}

LL 对象提供完整的自动补全:

Auto
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);
  };

  return (
    <select
      value={currentLocale}
      onChange={(e) => switchLocale(e.target.value as Locales)}
    >
      {locales.map((locale) => (
        <option key={locale} value={locale}>
          {LABELS[locale]}
        </option>
      ))}
    </select>
  );
}

进阶:带参数的翻译#

typesafe-i18n 支持带完整类型检查的参数:

TypeScript
// app/i18n/en/index.ts
const en = {
  // ...
  greeting: 'Hello, {name:string}!',
  items: '{count:number} {count|item}',
} satisfies BaseTranslation;

使用方式:

TSX
LL.greeting({ name: 'Asuka' })  // "Hello, Asuka!"
LL.items({ count: 5 })          // "5 items"

参数类型是完全推断的:

TSX
LL.greeting({ name: 123 })  // ❌ 类型错误:name 必须是 string
LL.greeting({})             // ❌ 类型错误:缺少必需参数

总结#

typesafe-i18n 为国际化带来了编译时安全性,同时不牺牲运行时性能。对于 React Router v7 SSR 项目的主要优势:

  • 类型安全 在翻译错误到达生产环境之前就能捕获
  • SSR 兼容 通过正确的 loader/clientLoader 配置实现
  • 最小的包体积 因为大部分工作在构建时完成
  • 优秀的开发体验 完整的自动补全和错误提示

初始配置比传统 i18n 库需要更多文件,但长期维护的收益是值得的。再也不用在生产环境中追查缺失的翻译键了!

资源#