
一句话:国际化(i18n)的核心是「文案与代码分离 + 运行时切换」(用
react-i18next/next-intl,配合Intl做日期数字格式化);主题的核心是「CSS 变量 +data-theme属性」(零运行时、无闪烁)。
一、i18n 方案
| 方案 | 特点 | 适合 |
|---|---|---|
| react-i18next | 生态最成熟、功能全(复数、插值、命名空间、懒加载) | 大多数 React 项目 |
| FormatJS(react-intl) | 基于 ICU 标准、能力严谨 | 需要严格 ICU 支持的场景 |
| next-intl | 为 Next.js App Router 设计(Server Component 友好) | Next.js 项目 |
| 自研(简单 Map) | 无依赖、可控 | 文案极少、无复数需求 |
| Lingui / Paraglide | 编译时提取文案(零运行时) | 追求体积与性能 |
⭐ 选型建议:
① Next.js App Router → next-intl(与 RSC/SSR 集成最好)
② 普通 SPA → react-i18next(生态最全)
③ 需要严格 ICU(复数/选择器复杂) → FormatJS
④ 只要几段文案 → 自研 Map 就够(别上框架)
二、文案的组织
src/
├── locales/
│ ├── zh-CN/
│ │ ├── common.json 通用文案(按钮、状态)
│ │ ├── auth.json 登录/注册
│ │ └── orders.json 订单模块
│ └── en-US/
│ ├── common.json
│ ├── auth.json
│ └── orders.json
// locales/zh-CN/auth.json
{
"login": {
"title": "登录",
"email": "邮箱",
"password": "密码",
"submit": "登录",
"errors": {
"invalidEmail": "邮箱格式不正确",
"wrongCredentials": "邮箱或密码错误"
}
},
"welcome": "欢迎回来,{{name}}",
"itemCount": "共 {{count}} 项",
"itemCount_plural": "共 {{count}} 项"
}
⭐ 文案组织的三条原则:
① 【按「命名空间」拆文件】——common / auth / orders,避免一个巨型 JSON
→ 好处:可懒加载(进入某页才加载对应文案)
② 【用「语义化 key」而不是「原文」】
❌ { "登录": "Login" } (key 是中文,改文案要改 key)
✅ { "login.submit": "Login" }(key 稳定,改文案只改 value)
③ 【不要拼接句子】——用「插值」而不是字符串相加
❌ t('hello') + name + t('suffix') (其它语言的语序可能不同!)
✅ t('welcome', { name })
⚠️ 「不要拼接」是 i18n 最容易被忽略的坑:
中文:「我买了 3 个苹果」
英文:「I bought 3 apples」——语序、复数、冠词都不同
如果把句子拆成「我买了」+「3」+「个苹果」再拼,
英文就无法正确组合(甚至日语/阿拉伯语的语序完全不同)。
→ 必须用「带插值的完整句子」。
三、使用方式
// i18n.ts —— 初始化
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
i18n
.use(Backend) // 从 /locales/{{lng}}/{{ns}}.json 加载(支持懒加载)
.use(LanguageDetector) // 自动检测浏览器语言
.use(initReactI18next)
.init({
fallbackLng: 'zh-CN',
ns: ['common'],
defaultNS: 'common',
interpolation: { escapeValue: false }, // React 已经做了 XSS 转义
});
export default i18n;
// ✅ 在组件里使用
import { useTranslation } from 'react-i18next';
function LoginForm() {
const { t } = useTranslation(['auth', 'common']); // ⭐ 指定命名空间
return (
<form>
<h1>{t('auth:login.title')}</h1> {/* ⭐ ns:key 语法 */}
<label htmlFor="email">{t('login.email')}</label> {/* 默认命名空间可省略 */}
<input id="email" />
<button>{t('common:button.submit')}</button>
{/* 插值 */}
<p>{t('auth:welcome', { name: user.name })}</p>
{/* 复数(i18next 约定:count 会选 itemCount_one / _other 等) */}
<p>{t('auth:itemCount', { count: items.length })}</p>
{/* 带格式的插值(React 元素) */}
<p>{t('auth:agreement', { terms: <a href="/terms">条款</a> })}</p>
</form>
);
}
// 复数与插值的 JSON 结构(i18next 约定)
{
"itemCount_one": "共 {{count}} 项",
"itemCount_other": "共 {{count}} 项"
}
// 英文的复数(英文有单复数两种形式)
{
"itemCount_one": "{{count}} item",
"itemCount_other": "{{count}} items"
}
// ✅ 在「非组件」里使用(如请求层、工具函数)
import i18n from '@/i18n';
toast.error(i18n.t('common:error.network'));
// ✅ 类型安全(增强 i18next 的类型,让 key 有提示)
// src/types/i18next.d.ts
import 'i18next';
import common from '@/locales/zh-CN/common.json';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: 'common';
resources: { common: typeof common };
}
}
四、语言切换与持久化
// ✅ 语言切换组件
import { useTranslation } from 'react-i18next';
const LANGUAGES = [
{ code: 'zh-CN', label: '简体中文' },
{ code: 'en-US', label: 'English' },
{ code: 'ja-JP', label: '日本語' },
];
function LanguageSwitcher() {
const { i18n } = useTranslation();
const change = (code: string) => {
i18n.changeLanguage(code); // ⭐ 切换(会触发所有使用 t 的组件重渲染)
localStorage.setItem('lang', code); // 持久化
document.documentElement.lang = code; // 更新 <html lang>(利于 a11y 与 SEO)
};
return (
<select value={i18n.language} onChange={(e) => change(e.target.value)}>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
);
}
⭐ 语言状态的「四个存放位置」(要一致):
① 【内存】i18n 实例的当前语言
② 【持久化】localStorage / cookie(记住用户选择)
③ 【DOM】`<html lang="zh-CN">`(无障碍与 SEO)
④ 【URL】(如果是「多语言站点」)——见下一节
⚠️ 如果 SSR:
· 要从「cookie 或 URL」读语言(服务端无法读 localStorage)
· 服务端渲染的文案必须与客户端一致(否则 hydration mismatch,见 9.4)
五、日期、数字、货币格式化
// ✅ 用「Intl」(浏览器原生,不需要额外库)
function Formatters({ amount, date }: { amount: number; date: string }) {
const { i18n } = useTranslation();
const locale = i18n.language;
// 日期
const formattedDate = new Intl.DateTimeFormat(locale, {
year: 'numeric', month: 'long', day: 'numeric',
}).format(new Date(date));
// 相对时间
const relative = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
// 数字(千分位)
const formattedNumber = new Intl.NumberFormat(locale).format(1234567.89);
// 货币
const formattedCurrency = new Intl.NumberFormat(locale, {
style: 'currency', currency: 'CNY',
}).format(amount);
// 百分比
const formattedPercent = new Intl.NumberFormat(locale, {
style: 'percent', minimumFractionDigits: 1,
}).format(0.856);
return (
<ul>
<li>{formattedDate}</li>
<li>{relative.format(-3, 'day')}</li>
<li>{formattedNumber}</li>
<li>{formattedCurrency}</li>
<li>{formattedPercent}</li>
</ul>
);
}
⭐ 「格式化」的两个重点:
① 【用 Intl 而不是自己拼】——它自带「各语言的正确格式与规则」
(如「千分位符号」「日期顺序」「货币符号位置」在不同语言下都不同)
② 【SSR 要显式指定时区与 locale】——否则服务端(UTC)与客户端(本地时区)
产生不同结果 → hydration mismatch(见 9.4)
// ⚠️ SSR 场景:显式指定时区
new Intl.DateTimeFormat(locale, { timeZone: 'Asia/Shanghai', ... }).format(date);
// 或统一用「UTC + 固定格式」,避免因「服务器时区」不同而变
六、多语言站点的路由(URL 与 SEO)
三种 URL 策略:
① 子路径(推荐):example.com/zh-CN/blog、example.com/en-US/blog
· 优点:SEO 友好(每种语言有独立 URL)、便于 CDN 缓存
② 子域名:zh.example.com、en.example.com
③ 查询参数:example.com/blog?lang=zh(❌ 不推荐,SEO 差)
⭐ 关键 SEO 要素:
① 每种语言一个独立 URL(爬虫能分别索引)
② `<html lang="zh-CN">`(当前页面语言)
③ `hreflang` 标签(告诉搜索引擎「这些 URL 是同一内容的不同语言版本」)
④ canonical 指向「自己语言的版本」(不是指向默认语言)
// ✅ 在 <head> 里声明 hreflang
// Next.js 的 metadata API
export const metadata: Metadata = {
alternates: {
canonical: 'https://example.com/zh-CN/blog',
languages: {
'zh-CN': 'https://example.com/zh-CN/blog',
'en-US': 'https://example.com/en-US/blog',
'x-default': 'https://example.com/blog', // ⭐ 未匹配语言时的默认页
},
},
};
// next-intl 的 App Router 用法(结构示意)
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
const locales = ['zh-CN', 'en-US'] as const;
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale as (typeof locales)[number])) notFound();
const messages = await getMessages();
return (
<html lang={locale}>
<body>
<NextIntlClientProvider messages={messages}>{children}</NextIntlClientProvider>
</body>
</html>
);
}
// ⭐ 语言作为「路由参数」→ 服务端与客户端都从这里读 → 天然一致(无水合问题)
七、主题切换(无闪烁实现)
/* ① 用 CSS 变量定义主题 */
:root {
--color-bg: #ffffff;
--color-fg: #111827;
--color-brand: #2563eb;
--color-border: #e5e7eb;
}
[data-theme='dark'] {
--color-bg: #0f172a;
--color-fg: #e5e7eb;
--color-brand: #60a5fa;
--color-border: #334155;
}
body {
background: var(--color-bg);
color: var(--color-fg);
transition: background-color 0.2s, color 0.2s; /* ⚠️ 可选:过渡会有「渐变动画」 */
}
<!-- ② 在 index.html 的 <head> 里「渲染前」确定主题(避免 FOUC) -->
<script>
(function () {
try {
var saved = localStorage.getItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
var theme = saved || system;
document.documentElement.dataset.theme = theme;
} catch (e) {}
})();
</script>
<!-- ⭐ 这段必须「内联且同步」(在 CSS/内容之前执行),否则会闪 -->
// ③ React 里的主题管理
type Theme = 'light' | 'dark' | 'system';
function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
// ⚠️ SSR 时不能读 localStorage → 用「默认值」保证两端一致
if (typeof window === 'undefined') return 'system';
return (localStorage.getItem('theme') as Theme | null) ?? 'system';
});
useEffect(() => {
const root = document.documentElement;
const apply = () => {
const resolved =
theme === 'system'
? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
: theme;
root.dataset.theme = resolved;
};
apply();
localStorage.setItem('theme', theme);
// 跟随系统变化
if (theme === 'system') {
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => apply();
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}
}, [theme]);
return { theme, setTheme };
}
// ④ 用 Context 提供给全局(避免每个组件都读 localStorage)
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const value = useTheme();
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useThemeContext() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useThemeContext 必须在 ThemeProvider 内使用');
return ctx;
}
⭐ 主题切换的三个要点:
① 【CSS 变量 + data-theme】——切换只改「一个属性」,不重新生成样式(零运行时)
② 【首屏无闪烁(无 FOUC)】——在 HTML 里内联脚本「渲染前」设置主题
(或用 SSR:服务端从 cookie 读主题并注入 <html data-theme>)
③ 【三种模式】light / dark / system——system 模式要监听 `prefers-color-scheme`
⚠️ SSR 场景下的主题(Next.js):
localStorage 在服务端读不到 → 用「cookie」:
① 用户切换主题时同时写 cookie
② 根布局 `cookies().get('theme')` → 设置 `<html data-theme={theme}>`
→ 服务端输出的 HTML 就带正确主题 → 客户端水合一致(无 mismatch、无闪烁)
八、与 SSR 的配合(避免 mismatch)
⚠️ i18n + SSR 的三个坑:
① 【文案不一致】
服务端用「探测到的语言」,客户端用「localStorage 里的语言」→ 不一致
✅ 统一来源:都用「URL 参数 / cookie」(服务端能读到的)
② 【日期/数字格式不一致】
服务端 UTC、客户端本地时区 → 格式化结果不同
✅ 显式指定 timeZone 与 locale
③ 【语言检测闪烁】
服务端输出 default 语言,客户端立刻切成用户语言 → 内容闪变
✅ 让「第一步的语言判定在服务端完成」(URL/cookie)
// ✅ 语言作为「路由参数」是最稳的做法(服务端与客户端天然一致)
// app/[locale]/page.tsx
export default async function Page({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
const t = await getTranslations({ locale }); // 服务端取翻译
return <h1>{t('home.title')}</h1>; // ⭐ 服务端已按 locale 渲染
}
九、常见坑
// ① 用「拼接」组装句子(其它语言语序/复数不对)
t('iBought') + count + t('apples') // ❌
t('boughtApples', { count }) // ✅
// ② 用「中文当 key」(改文案要改 key,且无法复用)
{ "登录": "Login" } // ❌
{ "login.submit": "Login" } // ✅
// ③ 把文案「硬编码」在组件里(漏掉的地方永远不翻译)
<button>提交</button> // ⚠️ 需要 t('submit')
// ④ 忘记「翻译缺失」时的兜底(显示 key 或空白)
fallbackLng: 'zh-CN', saveMissing: true // ✅
// ⑤ 复数只写单一形式(英文的 item/items 不分)
// ⑥ 日期用「手写格式」(英文/日文的日期顺序不同)
new Date().toISOString().slice(0, 10) // ⚠️ 只适合固定格式
new Intl.DateTimeFormat(locale).format(date) // ✅
// ⑦ SSR 下「服务端与客户端语言不一致」→ mismatch(见 9.4)
// ⑧ 主题「首屏闪烁」(没有内联脚本在渲染前设置)
// ⑨ 主题用「重新生成样式」(CSS-in-JS 切换主题)而不是 CSS 变量
// ⑩ 忘记「跟随系统主题」(prefers-color-scheme)
// ⑪ 主题切换「不持久化」(刷新后回到默认)
// ⑫ `<html lang>` 没更新(a11y 与 SEO 都会受影响)
// ⑬ 多语言站点「URL 不变」(SEO 差、无法分享特定语言)
// ⑭ 忘记 hreflang(搜索引擎不知道各语言版本的对应关系)
// ⑮ 文案文件「全量加载」(首屏加载所有语言的文案)→ 按需加载
// ⑯ 用「图片里的文字」做多语言(无法翻译)
// ⑰ 忽略「文本长度差异」(德语/俄语往往比英文长 30%+ → 布局溢出)
// ⑱ 忽略「RTL 语言」(阿拉伯语/希伯来语要从右往左布局)
⭐ 「RTL」的处理(如果支持阿拉伯语/希伯来语):
① 用逻辑属性(`margin-inline-start` 而不是 `margin-left`)
② `<html dir="rtl">`
③ 用 `dir="auto"` 让浏览器根据内容自动判断
④ 图标/箭头要「镜像」(用 CSS 的 `transform: scaleX(-1)` 或逻辑图标)
十、面试延伸
- 「前端国际化怎么做?」
三个层面:① 文案——用react-i18next/next-intl,把文案抽到「按命名空间拆分的 JSON」里,用t('ns:key', { 变量 })取用(支持插值与复数);② 格式化——用浏览器的Intl做「日期、数字、货币、相对时间、百分比」的本地化(不要手写格式);③ 语言状态——要在「内存(i18n 实例)+ 持久化(localStorage/cookie)+ DOM(<html lang>)+ URL(多语言站点)」四处保持一致。最关键的细节是「不要拼接句子」(用插值,否则语序与复数会出错)。 -
「为什么 i18n 不能用字符串拼接?」
因为「不同语言的语序、复数、冠词、性别规则完全不同」。例如中文「我买了 3 个苹果」,英文是「I bought 3 apples」(语序相同但复数变化),日语是「りんごを 3 つ買いました」(语序不同),阿拉伯语的数字与名词形式更复杂。如果把句子拆成「我买了」+「3」+「个苹果」再拼,其它语言根本无法正确组合(甚至无法翻译)。所以必须用「带插值的完整句子」:t('boughtApples', { count })。 -
「i18n 的 key 为什么不用原文(中文)?」
虽然「用原文当 key」({"登录": "Login"})写起来快,但有两个致命问题:① 改文案就要改 key(中文 key 变了,所有代码引用都要改),而「语义化 key」(login.submit)改文案只改 value;② 无法复用——「提交」在按钮、工具栏、弹窗里可能对应不同的英文(Submit / Apply / OK),用原文当 key 就区分不了。所以推荐「语义化 key + 命名空间」。 -
「复数怎么处理?」
不要自己写count === 1 ? 'item' : 'items'——因为不同语言的复数规则不同(英语 2 种、俄语 3 种、阿拉伯语 6 种、中文 1 种)。正确做法是用「支持 ICU 复数规则」的方案:i18next的约定是itemCount_one/itemCount_other(用t('itemCount', { count })会自动选),FormatJS/react-intl用 ICU MessageFormat 语法({count, plural, one {...} other {...}})。让库去处理规则,你只提供各形式的文案。 -
「主题切换怎么做到『无闪烁』?」
关键是「在页面渲染前就确定了主题」。三种做法:① 内联脚本(最简单)——在index.html的<head>里内联一段同步脚本,在 CSS 与内容之前读取localStorage并设置document.documentElement.dataset.theme;② 服务端注入(SSR)——服务端从 cookie 读主题并设置<html data-theme="dark">(客户端水合一致,既无闪烁也无 mismatch);③ 用框架的主题库(next-themes 等,它内部就是这么做的)。核心是「不能让客户端 JS 在首屏渲染后才改主题」。 -
「主题切换为什么推荐 CSS 变量而不是 CSS-in-JS?」
因为 CSS 变量切换主题只需「改一个属性」(document.documentElement.dataset.theme = 'dark')——CSS 会自动重新解析变量,不需要重新生成或加载样式(零运行时)。而 CSS-in-JS 的「主题切换」通常要「重新计算并插入大量样式」(有运行时开销,还可能出现「闪一下」)。此外 CSS 变量的方案天然支持「服务端预渲染正确主题」(服务端输出带data-theme的 HTML)。所以「CSS 变量 +data-*属性」是现代主题方案的主流。 -
「多语言站点的 URL 怎么设计?」
推荐「子路径」(example.com/zh-CN/blog、example.com/en-US/blog):① SEO 友好——每种语言有独立 URL,爬虫能分别索引;② 便于 CDN 缓存与分享(分享链接自带语言);③ 服务端能从 URL 读到语言(SSR 与客户端天然一致,避免 mismatch)。配套的 SEO 要素是:<html lang>、hreflang标签(告诉搜索引擎「这些 URL 是同一内容的不同语言版本」,含x-default)、以及「canonical 指向自己语言的版本」。不推荐查询参数(?lang=zh,SEO 差)。 -
「i18n 有哪些容易忽略的细节?」
七个:① 文本长度差异——德语/俄语往往比英文长 30%+,布局要留余量(别用固定宽度的按钮);② RTL 语言(阿拉伯语/希伯来语)需要dir="rtl"+ 逻辑属性(margin-inline-start而不是margin-left);③ 日期/数字格式必须用Intl(不同语言的分隔符、日期顺序、货币符号位置都不同);④ SSR 的时区与 locale 要显式指定(否则 hydration mismatch);⑤ 翻译缺失的兜底(fallbackLng+saveMissing);⑥ 文案的懒加载(不要首屏加载所有语言);⑦ 不要用图片承载文案(无法翻译、也不利于 SEO 与无障碍)。
一句话速记
i18n:选
react-i18next(SPA)/next-intl(Next.js);文案按「命名空间拆 JSON」+「语义化 key(不用中文当 key)」组织,取用用t('ns:key', { 变量, count });绝不要拼接句子(语序/复数会错),复数交给库(_one/_other或 ICU);日期数字货币用Intl(SSR 要指定timeZone与locale);语言状态要在「内存 + 持久化 +<html lang>+ URL」四处一致,多语言站点用「子路径 +hreflang」。主题:用「CSS 变量 +data-theme属性」(零运行时、切换只改一个属性);无闪烁的关键是「渲染前确定主题」(内联脚本读localStorage,或 SSR 从 cookie 注入<html data-theme>);支持「light/dark/system」三模式并监听prefers-color-scheme;RTL 要用逻辑属性;注意文本长度差异(德语/俄语更长)。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!