文章
在 React Router v7 中集成 typesafe-i18n#
构建多语言应用时,类型安全往往是事后才考虑的问题。大多数 i18n 库依赖字符串键,比如 t('home.welcome'),这种方式容易出错且缺乏 IDE 支持。typesafe-i18n 采用了不同的方案——在构建时生成完全类型化的翻译函数。
这篇文章将介绍如何将 typesafe-i18n 集成到 React Router v7 SSR 项目中。
为什么选择 typesafe-i18n?#
在深入实现之前,先看看 typesafe-i18n 的优势:
| 特性 | 优势 |
|---|---|
| 完全类型化 | 翻译键自动补全,编译时错误检查 |
| 极小的运行时 | gzip 后约 1KB,大部分工作在构建时完成 |
| 零依赖 | 没有外部依赖 |
| 框架无关 | 支持 React、Vue、Svelte 或原生 JS |
开发体验对比:
// ❌ 传统方式 - 没有类型安全
t('home.welcme') // 拼写错误直到运行时才会发现
// ✅ typesafe-i18n 方式
LL.home.welcome() // 自动补全 + 键不存在时编译报错
项目配置#
从一个新的 React Router v7 项目开始。假设你已经有一个基本的 SSR 配置,目录结构如下:
app/
├── routes/
│ ├── _app.tsx # 带导航的布局
│ └── _app._index.tsx # 首页
├── components/
├── root.tsx
└── routes.ts
第一步:安装 typesafe-i18n#
pnpm add typesafe-i18n
第二步:配置 typesafe-i18n#
在项目根目录创建配置文件:
// .typesafe-i18n.json
{
"$schema": "https://unpkg.com/typesafe-i18n@5.26.2/schema/typesafe-i18n.json",
"baseLocale": "en",
"outputPath": "./app/i18n/"
}
第三步:创建翻译文件#
创建英文基础翻译文件:
// 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;
第四步:生成类型#
运行 typesafe-i18n 生成器:
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#
// 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;
}
第六步:添加语言路由#
更新路由配置,添加语言前缀:
// 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 辅助函数#
// 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 的关键部分。需要在服务端和客户端都加载翻译:
// 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>
);
}
关键点:
loader在服务端加载翻译clientLoader在客户端 React hydration 之前加载翻译clientLoader.hydrate = true确保客户端 loader 在 hydration 期间运行HydrateFallback在过渡期间不显示任何内容,防止 hydration 不匹配
在组件中使用翻译#
现在可以在应用的任何地方使用完全类型化的翻译:
// 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 对象提供完整的自动补全:
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);
};
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 支持带完整类型检查的参数:
// app/i18n/en/index.ts
const en = {
// ...
greeting: 'Hello, {name:string}!',
items: '{count:number} {count|item}',
} satisfies BaseTranslation;
使用方式:
LL.greeting({ name: 'Asuka' }) // "Hello, Asuka!"
LL.items({ count: 5 }) // "5 items"
参数类型是完全推断的:
LL.greeting({ name: 123 }) // ❌ 类型错误:name 必须是 string
LL.greeting({}) // ❌ 类型错误:缺少必需参数
总结#
typesafe-i18n 为国际化带来了编译时安全性,同时不牺牲运行时性能。对于 React Router v7 SSR 项目的主要优势:
- 类型安全 在翻译错误到达生产环境之前就能捕获
- SSR 兼容 通过正确的 loader/clientLoader 配置实现
- 最小的包体积 因为大部分工作在构建时完成
- 优秀的开发体验 完整的自动补全和错误提示
初始配置比传统 i18n 库需要更多文件,但长期维护的收益是值得的。再也不用在生产环境中追查缺失的翻译键了!