做前端的应该都遇到过这种需求:多语言上线跑得好好的,产品突然说要加一个语言切换下拉框,还特别强调“不能整页刷新,要即时生效”。听到后半句,我就知道不能拿老方案凑合了——最粗暴的做法是跳链接带上语言参数,切到英文就往 /?lang=en 跳,整页 reload 一下就算完事。可用户正填到一半的表单、正在滚动的内容、刚打开的弹窗,全被一次刷新清零了,体验属实拉胯。
这次要讲的项目,核心就是“无需刷新实现React动态多语言切换”:在不重新加载页面的前提下,切换界面语言后所有文案、日期、数字、金额格式、校验提示,包括 html 标签上的 lang 属性,全部无感同步更新。文章会从技术选型讲到底层原理,再到完整代码和踩坑记录,适合正在做国际化改造、或者准备给老项目上多语言的 React 开发者参考,函数组件和类组件的写法都可以套用。
先说下背景。我这边负责的是模拟项目X,一个面向多个市场的中后台系统,文案规模在千条以上,最初跑的是“刷新式”多语言:以 URL 参数区分语言,切换必须重新加载。后来要做对外演示,需求直接升级成“顶部下拉切换、即时生效、不能丢页面状态”,于是就有了这个专项。做完以后回头看,核心难点不在于“翻译替换”,而在于“状态驱动与边界处理”,这篇文章就把这些全拆开讲。
1. 项目概述:动态切换到底卡在哪
1.1 刷新式切换的代价
很多老项目做多语言,第一反应都是 URL 上带个语言参数,后端或前端读取后渲染对应语言版本。切语言就是重新导航,整页刷新。这种方案实现成本确实低,但代价也很明显。
首先是页面状态全丢。用户在一个多条件的筛选列表里翻到第三页,填了半天的表单,或者打开了某个查看详情的抽屉,一个刷新全部归零。中后台系统里这种场景特别常见,数据一丢就得重填,非常影响体验。
其次是请求成本高。刷新意味着整个应用重新下载、重新执行一遍。就算静态资源有缓存,接口请求也免不了重跑一遍,尤其是列表、字典、权限这类基础数据,每次切语言都要再来一轮,资源浪费放在那的。
再就是体验上“顿挫感”明显。现在的 SPA 已经把加载时间压得很低,用户已经习惯页面内操作是即时响应的,突然切语言要白屏一下,哪怕只有几百毫秒,体感也是“卡”。当时要做的演示场景更是如此,站在大屏前等页面刷新很不体面。
这里不是全盘否定刷新式方案,它适合 SSR 渲染占主导、SEO 权重高的内容站点。但如果是重交互的中后台、SaaS 控制台,动态切换就是刚需。
1.2 “即时生效”的本质是状态驱动
不刷新页面,又要所有文案立刻变化,听起来像魔法,本质却很朴素:把“当前语言”当成一份全局状态,所有消耗文案的组件都去订阅它。语言一变,所有订阅者重新渲染,拿新的翻译资源渲染出来。这就是“即时生效”的全部秘密。
React 里这份状态放在哪、怎么传递、怎么触发更新,就是工程上要解决的问题。如果不借助成熟库,自己写一个 LanguageContext,把 t 函数放进去,再让每个组件 useContext,也能跑通,但很快会遇到一堆边角问题:组件忘接 Context 不更新、异步回调里拿到旧 t、嵌套 key 的取值逻辑重复写、复数规则没法统一处理。所以我更倾向于用现成的国际化库来承载这份状态,React 只负责消费它。
这里要补一个认知点:语言状态最好不要和业务数据状态混在一起。业务状态(比如用户输入、列表数据)用 Redux、Zustand 管理没问题,语言更像“环境变量”,它影响的是整个应用的渲染输出,而 i18next 这类库本身就把它管理得很好,再复制一份到状态管理库里反而是负担。
1.3 这个方案能解决什么、适合谁
这套方案做出来以后,解决的不只是“语言切换”这一个动作。它同时覆盖了语言持久化、首次访问语言判定、多语言资源的组织加载、日期数字格式本地化、错误提示本地化等一系列问题。对于有多个目标市场的产品,这些能力基本是必须的。
适合参考的人群有三类:一是给现成的 C 端或 B 端 React 项目加多语言支持的人;二是当前用的是刷新式方案,想升级为即时切换的人;三是刚开始接触前端国际化、想一次性把基础打牢的新手。文章里的代码以函数组件为主,类组件也完全能用,思路一致。
2. 技术选型:为什么这样组合
2.1 国际化库的几条路线
做 React 国际化,市面上主流就是 react-intl 和 react-i18next,不少人也会选择自研。我分别说下我的判断。
react-intl 是格式化方案的路子,组件式 API 比较重,语言切换时的全局重渲染需要自己处理,语言资源加载也没有内置方案,需要外部配合。它对单条消息的 ICU 格式化支持很强,但放在这个“切换即全局更新”的需求里,反而要多写不少胶水代码。
react-i18next 的思路是:i18next 核心负责语言状态、资源加载、插值、复数、命名空间,react-i18next 只是做一层 React 绑定,把 i18n 实例变成响应式的。调用 i18n.changeLanguage 后,所有使用 useTranslation 的组件都会自动重新渲染。这个特性正好踩在这个需求的核心上。
自研方案我不推荐主要原因是“知识成本被人数平摊了”。复数规则、插值嵌套、资源回退这些看起来是小功能,真做起来每一样都得维护,而且容易做出奇奇怪怪的 bug。除非你的项目只有两张语言表、几十条文案,否则别走这条。
2.2 为什么语言状态不需要放进全局状态库
很多人会惯性觉得“全局的东西都得进 Redux/Zustand”,语言也不例外。实际做了以后你会发现,i18next 实例本身就是全局单例,它内部持有了当前语言,并且通过事件机制通知所有订阅者。react-i18next 的 useTranslation 内部就是订阅了它的 languageChanged 事件,组件因此重新渲染。
所以语言这份状态,交给 i18next 管才是最省事的。你再把它复制一份存到 Zustand,反而要维护两处一致,万一不同步,会出现页面一半文案是新语言、另一半是旧语言的诡异情况。
当然也有例外:如果你需要在语言切换时联动触发某个业务动作,比如切换语言同时重置当前账单的币种,那你可以在语言变更的事件回调里 dispatch 一个业务 action,让业务状态去响应它。这是“联动”,不是“复制状态”。
2.3 整体技术组合
我这次用的组合是:
- i18next + react-i18next:核心语言状态与 React 绑定
- i18next-browser-languagedetector:自动检测浏览器语言,并持久化用户选择
- 原生 Intl API:处理日期、数字、货币格式化
- 普通 JSON 语言包 + 动态 import:实现按需加载
这个组合的好处是每层都职责单一,就算以后要换框架,i18next 核心还能继续复用。我在多个项目里用过,稳定性没得说。
3. 核心细节解析:从 changeLanguage 到全部组件重新渲染
3.1 语言资源目录与命名规范
语言资源我建议按“语言代码 + 命名空间”组织。语言代码用 BCP 47 格式,比如 zh-CN、en-US、ja-JP,避免只用 zh、en 这种两位代码,因为后面接入不同地区时你会后悔。命名空间可以按业务模块划分:common(公共)、header(顶部导航)、order(订单)、dashboard(看板)等等,避免所有文案挤在一个 JSON 里,几千条 key 后期根本没法维护。
一份语言包的典型结构长这样:
{ "common": { "save": "保存", "cancel": "取消", "confirm_delete": "确定要删除这条记录吗?" }, "header": { "nav": { "home": "首页", "orders": "订单管理" } }, "order": { "status": { "pending": "待处理", "shipped": "已发货" }, "list_empty": "暂无订单数据" } }key 命名不要太深,三级以上读起来就很累了。我更推荐“模块 + 语义”的扁平结构,比如 order.status.pending 这样的深度是极限。另外所有语言包的 key 结构必须保持一致,漏 key 没关系,漏得多了容易在代码里又把文案写死。
3.2 切换链路的完整过程
当用户点击下拉框的某一项时,发生的事可以用一条链路描述:
- 组件调用 i18n.changeLanguage(code)。
- i18next 内部把当前语言更新为 code,如果该语言的资源还没加载,会触发加载流程。
- 资源就绪后,i18next 发出 languageChanged 事件。
- react-i18next 收到事件,通知所有调用 useTranslation 的组件重新渲染。
- 组件用新的 t 函数拿到对应语言的文案。
这里很多人忽略的一点是:changeLanguage 是异步的,返回 Promise。如果你在调用后立刻读 i18n.language,它可能是旧值,这里要等 Promise resolve。写代码时养成 await 的习惯,避免后续加逻辑时踩到未就绪的坑。
3.3 非组件元数据要手动同步
React 组件能重新渲染,但页面标题、html 标签的 lang 属性、meta 描述这些东西不在 React 的渲染体系里,必须手动同步。这一步非常容易遗漏,但对 SEO 和无障碍访问都重要。
我在项目里封装了一个同步函数,挂在 languageChanged 事件上:
useEffect(() => { const syncMeta = () => { document.documentElement.lang = i18n.language; document.title = i18n.t('meta.title'); const desc = document.querySelector('meta[name="description"]'); if (desc) { desc.setAttribute('content', i18n.t('meta.description')); } }; syncMeta(); i18n.on('languageChanged', syncMeta); return () => { i18n.off('languageChanged', syncMeta); }; }, [i18n]);看起来很简单,但少了这段,切语言后浏览器的翻译插件会给出错误提示,页面标题也还是旧语言,属于“功能能用,细节拉胯”的典型问题。
3.4 日期、数字、货币格式的本地化
这一步很多人会漏。t 函数只负责“翻译字符串”,但订单金额、日期时间这些数据,在不同语言环境下展示格式是不一样的。比如同样是 2026 年 3 月 5 日,英文习惯 March 5, 2026,中文习惯 2026年3月5日,日文又不一样。这个不能用翻译件解决,得用 Intl API。
我封装了一个格式化 hooks:
export function useLocaleFormatters() { const { i18n } = useTranslation(); const locale = i18n.language; const formatPrice = (value: number, currency: string) => new Intl.NumberFormat(locale, { style: 'currency', currency, }).format(value); const formatDate = (date: Date | string) => new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long', day: 'numeric', }).format(new Date(date)); return { formatPrice, formatDate }; }这里的关键是 formatPrice、formatDate 每次语言变化都要重新创建,因为它们在闭包里捕获了 locale。如果把这个 hooks 用在表格列定义里,要确保列定义也跟随语言变化重新生成,这个问题我在后面的排查章节会专门讲。
4. 完整实操记录:一步步落地这个功能
4.1 安装与初始化
依赖不多,三个就够:
npm install i18next react-i18next i18next-browser-languagedetector初始化配置是核心,直接给代码:
// src/i18n/index.ts import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import zhCN from './locales/zh-CN.json'; import enUS from './locales/en-US.json'; import jaJP from './locales/ja-JP.json'; const resources = { 'zh-CN': { translation: zhCN }, 'en-US': { translation: enUS }, 'ja-JP': { translation: jaJP }, }; i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources, fallbackLng: 'en-US', supportedLngs: ['zh-CN', 'en-US', 'ja-JP'], interpolation: { escapeValue: false }, detection: { order: ['localStorage', 'navigator'], caches: ['localStorage'], lookupLocalStorage: 'app_locale', }, }); export default i18n;这几个配置项要解释一下。
fallbackLng 是兜底语言。当某个 key 在目标语言里找不到时,会去 fallback 语言里找,再找不到就直接显示 key 本身。设为 en-US 是因为英文资源通常最完整。
supportedLngs 用来限制可切换的语言范围,防止浏览器检测出奇怪的语言代码后乱套。
detection 配置里我刻意把 localStorage 放在 navigator 前面。这样已登录用户只要选过一次,下次进来直接用本地记录;新用户才用浏览器语言兜底。这是很多新手的误区——默认语言写死为中文,导致海外的第一次访问也看到中文。
4.2 语言切换组件
切换器本身很简单:
import { useTranslation } from 'react-i18next'; const LANG_OPTIONS = [ { code: 'zh-CN', label: '简体中文' }, { code: 'en-US', label: 'English' }, { code: 'ja-JP', label: '日本語' }, ]; export default function LanguageSwitcher() { const { i18n } = useTranslation(); const handleChange = async (code: string) => { await i18n.changeLanguage(code); document.documentElement.lang = i18n.language; }; return ( <select value={i18n.language} onChange={(e) => handleChange(e.target.value)} aria-label="语言切换" > {LANG_OPTIONS.map((opt) => ( <option key={opt.code} value={opt.code}> {opt.label} </option> ))} </select> ); }注意 select 的 value 绑的是 i18n.language。这里有个小坑:如果检测到 zh 而不是 zh-CN,value 对不上,select 会显示空白。解决方法是拉平语言映射,或者在检测配置里加上 convertDetectedLanguage 之类的处理,把 zh 规范成 zh-CN。实际项目里我更喜欢直接定义一张受支持语言的映射表,保证写入 localStorage 的值永远是规范代码。
4.3 应用入口接入
入口处用 I18nextProvider 把 i18n 实例注入,虽然 initReactI18next 默认是全局单例,不写 Provider 也能用,但显式注入有个好处:测试时方便换一个全新的 i18n 实例,不影响全局。
import { I18nextProvider } from 'react-i18next'; import i18n from '@/i18n'; root.render( <I18nextProvider i18n={i18n}> <App /> </I18nextProvider> );业务页面里正常使用:
const { t } = useTranslation('order'); // 模板字符串的写法 <p>{t('order.list_empty')}</p> // 带插值的写法 <p>{t('order.greeting', { name: userName })}</p>插值函数在翻译文件里对应的是 {{name}} 这种占位符,比如 “你好,{{name}}”。注意插值变量不要在翻译文本里写死格式,最好是纯文本占位,格式由代码控制。
4.4 表单校验消息的本地化
表单校验是重灾区。很多人会把校验消息写死在 schema 里,然后发现切完语言,报错还是旧语言。原因在于校验 schema 在组件初始化时就创建了,里面捕获的 t 函数是旧的。
我这边用的方案是“用函数创建 schema”:
// 拿到当前语言对应的 t const { t } = useTranslation('validation'); // 每次渲染都重新创建校验规则,或者配合 useMemo 依赖 t const schema = useMemo( () => z.object({ email: z.string().email(t('email_invalid')), password: z.string().min(8, t('password_min')), }), [t] );核心就一条:只要校验消息来自 t 函数,就必须保证 schema 与 t 同步更新。t 的身份是跟着语言走的,语言变,t 变,依赖 t 的 memo 也随之变化。
4.5 异步场景里的 t 函数
表单提交、接口返回错误提示这些场景,最容易踩“异步回调里用了旧 t”的坑。代码看起来没问题,实际上可能报错信息是英文、界面已经是中文了。
原因很简单:异步函数创建时捕获的 t 是那一刻的旧函数,而用户可能在请求期间切换了语言。
稳妥做法是不要在异步函数里直接引用组件作用域的 t,而是用 i18n.getFixedT(null, 'namespace') 现场取:
const handleSubmit = async () => { try { await saveData(formData); } catch (err) { const fixedT = i18n.getFixedT(null, 'common'); notification.error(fixedT('save_failed')); } };getFixedT 可以指定语言,传 null 表示当前语言,返回的是最新语言环境的 t 函数。这样就算用户在请求期间切了几次语言,提示文案也跟着最终语言走。
5. 常见问题与排查实录
5.1 切换后有一半组件没变
这是动态切换最典型的症状。表现出来是页面大部分文案变了,但某个表格、某个弹窗、某个侧边栏还是旧语言。
排查路径我建议从下往上查:先看那个组件是不是用了 useTranslation,再看它有没有被 React.memo 包住、父组件有没有把 t 作为 props 传下去。
常见原因有两个:
一是组件被 React.memo 缓存了,而父组件重渲染时传给它的 props 没有变化,memo 就直接跳过了。解决方法很简单,在组件内部调用 useTranslation,让组件自身订阅语言变化;不要把 t 从父组件传进去,因为 t 变化不一定触发 memo 组件的更新。
二是组件内部用 useMemo 缓存了文案,但依赖数组里漏了 t:
const columns = useMemo( () => buildColumns(), // 里面用了 t,但依赖里没写 // 缺少 [t] );修复就是依赖数组加上 t。记住 t 这个函数引用会随语言变化,把它放进依赖,memo 就能正确失效。
5.2 快速连续切换出现语言串位
有一种隐蔽的 bug:用户快速地从 中文 → 英文 → 日文 连续点,最后界面上有的是英文、有的是中文,状态混乱。
原因是 changeLanguage 是异步的,尤其是按需加载的场景,第一次请求还没返回,第二次切换又发起了。两个请求的完成顺序不一定和点击顺序一致,后点击的反而先完成,于是语言状态被旧请求覆盖。
我的修复是对语言切换加一个“请求序号”守卫:
const langRef = useRef(i18n.language); const handleChange = async (code: string) => { const seq = ++langRef.current; // 每次切换递增 await i18n.changeLanguage(code); if (seq < langRef.current) { // 说明期间又有新的切换,这次结果是过期的,丢弃 return; } document.documentElement.lang = i18n.language; };这个 seq 的写法类似请求竞态的 cancel token,虽然不是真正的取消,但能保证“最后一次点击才是最终生效状态”。
5.3 切换后页面闪一下白屏
白屏一般出现在语言包按需加载的场景。react-i18next 默认对未加载完成的资源走 Suspense,如果资源没拿到,组件会挂起,Suspense 的 fallback 就显示出来了,看起来就是闪白。
解决办法要么把 Suspense 的 fallback 做成一个很轻的占位,避免整页空白;要么在初始化时把 useSuspense 设为 false,让组件在资源缺失时先用原 key 渲染,资源到了再刷新。
我个人更推荐配合加载策略:首屏用的语言包提前打包进主 bundle,不常用的语言包才按需加载。这样首屏永远不挂起,切到稀有语言时即使短暂显示英文兜底,也比白屏体感好很多。
5.4 页面标题和 lang 属性没变
这个属于漏网之鱼,功能上不影响用户看内容,但影响 SEO、无障碍和浏览器翻译行为。排查就是检查 languageChanged 事件有没有同步 document.title 和 document.documentElement.lang。如果项目里有多个路由、每个路由有自己的标题,需要把“当前路由文案”也纳入同步逻辑,做法是在路由切换时重新读一次标题模板。
5.5 表格里的日期和金额格式没变
问题基本都出在“列配置只生成了一次”。表格列定义里用了 formatDate,但列数组在组件 mount 时生成,之后不再更新,语言变了也没有重建。
排查时看两点:一是格式化函数的 locale 是否实时读取,二是列配置是否挂在 useMemo 上并依赖了 t。用了我前面封装的 useLocaleFormatters 之后,还要保证每个使用格式化结果的组件都订阅了语言变化。
有些图表库也一样,tooltip、坐标轴格式是配置对象传入的,语言切换后图表不会自我刷新。处理方式是监听语言变化,强制重建图表配置。
5.6 外部 iframe 和第三方组件不跟随
中后台里经常嵌报表 iframe。语言切换只影响当前 document,iframe 是独立文档,天然不跟随。这个不是 React 问题,是浏览器安全模型决定的。
我的处理是切换语言时给 iframe src 追加语言参数,iframe 内部自己读取并重渲染。更复杂一点用 postMessage 通知子页面,子页面监听后切换自己的语言。第三方组件则要看它有没有提供 locale 切换 API,比如一些富文本编辑器、日历组件,切换后必须手动调用 setLocale。
到这里,核心功能已经完整落地,剩下的是性能和后续演进的问题。
6. 性能优化与后续扩展
6.1 语言包按需加载
项目文案过千条之后,把所有语言包打进主 bundle 会明显增加首包体积,尤其像日文、韩文、阿拉伯文这种字符集较大的语言。我的做法是拆成独立 chunk,用动态 import 在切换时加载:
async function loadLanguage(lang: string) { const module = await import(`./locales/${lang}.json`); i18n.addResourceBundle(lang, 'translation', module.default); await i18n.changeLanguage(lang); }这个写法注意两件事:一是 addResourceBundle 的第五个参数要传 true,覆盖同名资源;二是 import 路径必须是字面量,不能是变量拼得过于动态,否则打包工具无法静态分析。生产构建后每个语言包独立成一个 JSON chunk,第一次切过去时才有一次额外请求,体感几乎无感。
6.2 首屏语言判定策略
首屏语言判定决定用户进来第一眼看到什么。我之前遇到过一个问题:某海外用户浏览器语言是 en-US,但公司规定默认中文先行,于是把默认语言写死成中文,结果全球访问都先看到中文界面,体验很怪。
正确策略是用户偏好优先、浏览器语言次之、业务默认最后兜底:
- localStorage 里有用户主动选择,用它。
- 没有就用 navigator.language。
- 不在 supportedLngs 里才回退到 fallbackLng。
这套流程 i18next-browser-languagedetector 默认就是按这个思路走的,关键是你不要再额外覆盖默认语言。如果业务确实要求某个地区默认显示特定语言,可以在拿到访客地区信息后,用 setDefaultNamespace 或初始化参数动态指定,而不是写死。
6.3 RTL 布局与阿拉伯语扩展
如果哪天要加阿拉伯语,除了翻译资源,还有布局方向问题。阿拉伯语、希伯来语是 RTL,界面要整体镜像。
同步 lang 属性的代码稍作扩展:
const RTL_LANGS = ['ar', 'he']; const isRTL = RTL_LANGS.includes(i18n.language); document.documentElement.dir = isRTL ? 'rtl' : 'ltr';CSS 侧建议用逻辑属性替换物理属性:用 margin-inline-start 代替 margin-left,用 padding-inline-end 代替 padding-right。这样布局方向切换时不用写两套样式。
6.4 测试策略
多语言切换的测试重点不是“文案显示正确”,而是“切换后所有动态渲染内容都更新”。单元测试里可以 mockuseTranslation,断言组件在语言变化后的输出。端到端测试则要做一次完整切换,检查 URL 没变、localStorage 写入正确、关键文案变化、页面标题变化。
还有一个很容易忽略的测试点:切换后表单里已填入的数据不能丢。这是“无刷新”相对于“刷新式”方案最大的价值,测试时务必覆盖。
最后说点个人体会。这个功能做完,我最大的感受是:表面上是“翻译替换”,实际上拼的是“状态管理与边界处理”。t 函数谁都在用,但真正写得好的项目,一定把“t 的获取时机”当成一等公民来设计。异步回调用 getFixedT、memo 依赖带上 t、格式化和校验规则跟随语言重建,这几个习惯一旦养成,后面扩展多少语言都不慌。
再补一个小技巧:语言切换后如果想给用户一个确认感,可以在下拉框旁边短暂显示一条“语言已切换”的轻提示。这是我后来在实际使用中加的,体感比闷声切换好很多。这个内容后续还能继续扩展,比如把语言偏好同步到账号体系后端存储,实现换设备自动恢复,探索的空间还是不小的。