资讯动态

React多语言实现从零到一:手写方案到i18next工程化落地

发布时间:2026/10/13 5:32:34 来源:尧图企业网站定制
我最早是从一个管理后台开始接触React多语言实现的。当时后台只有中英两份文案按钮也不算多我一度觉得这件事不值得专门花精力。直到后来订单模块的文案超过三百条还要根据用户上一次的选择动态切换语言我才意识到从零开始构建React下的多语言实现并不是把你好替换成Hello那么简单。它牵扯到语言包怎么组织、组件怎么重渲染、异步资源怎么加载、日期数字怎么跟着语言变化甚至还有团队协作时谁来维护Key的问题。这篇文章把我从零到一走过的完整路径拆开来讲适合正在做React项目、打算接入多语言或者已经用了一段时间但总被各种小问题缠住的人。1. 先分清手写React多语言和引入i18next的边界很多人第一反应是自己写一个多语言方案觉得一个翻译字典加一个Context就够用了。确实小项目完全能跑通但规模和复杂程度上来之后手写方案会一步步逼近崩溃边缘。我先把这个边界讲清楚。1.1 用Context实现一个最简翻译方案半小时就能跑通如果项目只有几个页面、十几条文案完全可以用一个最简单的Context方案撑住。核心代码其实很少import { createContext, useContext, useState } from react const messages { zh: { hello: 你好 }, en: { hello: Hello }, } const I18nContext createContext(null) export function I18nProvider({ children }) { const [lang, setLang] useState(zh) const t (key) messages[lang]?.[key] ?? key return ( I18nContext.Provider value{{ lang, setLang, t }} {children} /I18nContext.Provider ) } export function useI18n() { return useContext(I18nContext) }组件里这样用function Greeting() { const { lang, setLang, t } useI18n() return ( div p{t(hello)}/p button onClick{() setLang(lang zh ? en : zh)} Switch Language /button /div ) }这个方案的优势是零依赖代码透明出现问题时自己就能定位。缺点也很直接所有翻译文件在构建时已经硬编码进包里语言包只能在主线程同步加载切换语言时会有一段明显卡顿。不过作为练手和概念验证它比任何框架都直观。1.2 复杂业务场景下手写方案首先扛不住的三个地方我实际从手写方案迁移到i18next是因为三个具体场景逼的。第一个是插值和复数。比如你有3个待处理订单英文要区分单复数中文虽然不区分但业务一旦需要拼接用户名、时间、按钮动作手写t函数就要维护一整套模板字符串逻辑容易越写越脏。第二个是异步加载。如果首屏只加载当前语言切换语言时才去加载另一份JSON那么手写方案必须自己设计缓存机制、加载状态、异常回退这套东西自己造一遍很不值得。第三个是Key的全局管理。业务模块多了以后不同组件会写出t(title)这种完全相同的Key互相覆盖或找不到排错时会非常痛苦。这是四个模块左右的规模问题就会集中爆发。如果是单页面小产品倒是可以选择手写方案一旦项目有接口层、有权限、有多个业务域老老实实上成熟方案更划算。1.3 i18next替我们解决了什么又带来了什么约束i18next是一个翻译引擎react-i18next是它和React之间的适配层。我用下来它解决的核心问题是四块第一语言资源可以被组织成命名空间按模块拆分按需加载第二内置变量插值、复数、上下文、嵌套Key等能力不用自己写模板逻辑第三支持同步初始化也支持异步后端加载切换语言时可以返回Promise第四配合i18next-browser-languagedetector可以从localStorage、cookie、URL参数或浏览器语言偏好里自动判断当前语言并缓存用户选择。代价是它带来了一些配置约束和默认行为。比如默认启用Suspense异步加载语言包时组件树需要包一层Suspense比如Key里有.会被当成层级分隔符如果团队人员的Key命名习惯是order.list.title资源文件就必须嵌套成对应结构。这些不是问题但接项目之前必须让团队知道。2. 环境准备初始化React项目、依赖选择和语言包目录从空目录开始我们需要先把项目搭起来再决定语言文件放在哪里。这一步如果图省事后面重构成本会很高。2.1 依赖安装的版本组合顺手列出坑点我当前搭建项目会先用Vite初始化React应用npm create vitelatest my-i18n-app -- --template react cd my-i18n-app npm install接着安装多语言需要的依赖npm install i18next react-i18next i18next-browser-languagedetector i18next-http-backend四个包各自的用途我整理成了一张表包名用途注意事项i18next翻译引擎核心负责语言包解析、插值、复数尽量使用当前稳定版老版本插件兼容性差react-i18nextReact绑定层提供useTranslation、Trans组件和i18next主版本需要匹配i18next-browser-languagedetector自动检测浏览器语言并缓存用户选择可以用localStorage或cookiei18next-http-backend从服务器按路径加载语言包小项目可以不用直接本地import这里重点提一个坑不同版本组合可能直接导致启动报错。我自己遇到过把i18next升到不兼容的大版本后react-i18next的initReactI18next插件报Element type is invalid。排查了半天最后发现是两个包版本不一致造成的。所以安装时别全写latest最好在package.json里把大版本锁住升级时一起升。2.2 语言资源文件的目录规范按命名空间而不是按页面放语言文件放哪里决定了后续维护的舒服程度。我见过有人把所有文案都塞进一个locales.js最后同事改Key都靠搜索。更推荐按语言和命名空间两层组织src/ locales/ zh/ common.json order.json profile.json en/ common.json order.json profile.json这里common放全局共享文案比如保存、取消、确定、加载中order、profile等按业务域划分而不是按页面划分。原因很简单一个订单列表组件可能会在首页、列表页、详情页里都被复用如果语言文件按页面组织同一个Key会重复出现维护时你根本不知道改哪一份。2.3 一个翻译JSON文件的基本结构以order.json为例我习惯使用嵌套结构{ appName: 订单管理后台, list: { title: 订单列表, empty: 暂无订单, status: { pending: 待处理, paid: 已付款 } }, detail: { title: 订单详情, createdAt: 创建时间 } }i18next默认把.作为层级分隔符所以组件里可以通过t(list.title)拿到订单列表。如果某个Key名本身包含点号比如产品定义的文案ID是order.count那么要么把资源JSON改成非嵌套结构并在初始化时设置keySeparator: false要么干脆避免这种命名。团队一旦混用排查起来非常烦。3. 核心初始化从i18next配置到React挂载依赖装好、语言文件准备好了接下来就是整个项目最关键的一步把i18next初始化并挂到React上。这一步做稳了后面业务组件接入就是纯粹的体力活。3.1 i18n配置文件的推荐写法我习惯把初始化代码独立放在src/i18n/index.js不让它和React组件混在一起。import i18n from i18next import { initReactI18next } from react-i18next import LanguageDetector from i18next-browser-languagedetector import zhCommon from ../locales/zh/common.json import enCommon from ../locales/en/common.json import zhOrder from ../locales/zh/order.json import enOrder from ../locales/en/order.json i18n .use(LanguageDetector) .use(initReactI18next) .init({ resources: { zh: { common: zhCommon, order: zhOrder, }, en: { common: enCommon, order: enOrder, }, }, fallbackLng: zh, supportedLngs: [zh, en], ns: [common, order], defaultNS: common, interpolation: { escapeValue: false, }, }) export default i18n这里有几个配置项需要解释。fallbackLng: zh表示当某个语言缺少Key时回退到中文避免页面出现裸Key。supportedLngs限制可用的语言列表。ns声明了命名空间defaultNS指定不带命名空间前缀时默认查哪个命名空间。interpolation.escapeValue: false是React场景下的常见选择因为React本身会转义输出内容如果i18next再转义一次就可能会把正常的引号、尖括号显示成乱码。3.2 在入口文件里初始化注意异步顺序初始化要在React组件挂载前完成最直接的方式是在入口文件里导入import React from react import ReactDOM from react-dom/client import App from ./App import ./i18n ReactDOM.createRoot(document.getElementById(root)).render( React.StrictMode App / /React.StrictMode )这里有个容易踩的坑不要在组件的render函数里写i18n.init()这样每次组件渲染都会重新初始化热更新时尤其混乱。i18next初始化本身是幂等的但资源绑定和检测插件重复执行会带来不可控的副作用。如果你的语言包是通过http-backend异步加载的那么React渲染时可能语言包还没就绪react-i18next默认会触发Suspense。这时候要在组件树外层加Suspense兜底import { Suspense } from react import App from ./App ReactDOM.createRoot(document.getElementById(root)).render( Suspense fallback{div语言包加载中.../div} App / /Suspense )本地import语言包的方案其实不会触发异步但加上Suspense可以兼容后续切换到后端加载。3.3 语言检测、记忆和切换的实际代码i18next-browser-languagedetector最常见的配置是让它在初始化时先读取localStorage里用户上次选择的语言读不到再判断浏览器语言detection: { order: [localStorage, navigator], caches: [localStorage], lookupLocalStorage: my_app_lang, },order表示检测顺序caches表示把最终确定的语言写回localStoragelookupLocalStorage可以自定义存储键名。如果不设置默认键名是i18nextLng。我建议显式设置一个项目前缀避免和同域下其他项目冲突。切换语言时业务组件里一般是这样的import { useTranslation } from react-i18next function LanguageSwitcher() { const { i18n } useTranslation() const changeLang (lang) { i18n.changeLanguage(lang) document.documentElement.lang lang } return ( select value{i18n.resolvedLanguage} onChange{(e) changeLang(e.target.value)} option valuezh中文/option option valueenEnglish/option /select ) }i18n.changeLanguage返回一个Promise异步加载语言包时可以在await之后再做需要依赖新语言的页面数据刷新。另外不要忘记同步设置document.documentElement.lang屏幕阅读器、浏览器翻译插件和SEO都会读取这个属性。4. 业务接入useTranslation、Trans组件和动态内容初始化做完真正的改造工作才开始。这里我有一套固定的操作顺序团队新人也比较容易上手。4.1 现有代码改成多语言的五步操作法第一步找出组件里所有硬编码的字符串比如保存、取消、订单详情。第二步给它起一个规范Key先看它是不是通用文案是则进common否则进对应业务命名空间。第三步用const { t } useTranslation(order)拿到翻译函数把字符串替换成t(list.title)。第四步在src/locales/zh/order.json和en/order.json里同时补上Key。第五步本地跑起来切语言验证一遍。以order命名空间为例import { useTranslation } from react-i18next function OrderListHeader() { const { t } useTranslation(order) return h1{t(list.title)}/h1 }注意这里useTranslation(order)传了命名空间后面t(list.title)就不需要带前缀。如果某个Key在common里可以写成t(common:save)或者切换命名空间。4.2 变量插值、复数规则和上下文参数业务文案不可能全是静态字符串最常见的就是你好张三和你有3个待处理订单。变量插值用法t(welcome, { name: userName })对应的JSON{ welcome: 你好{{name}} }复数规则是i18next最让我省心的功能。JSON里定义_one和_other{ orderItem_one: {{count}} 个订单, orderItem_other: {{count}} 个订单 }组件里传count参数即可t(orderItem, { count: orderCount })i18next会根据当前语言的复数规则自动选择对应后缀。英文会把count: 1映射到_one其他数量映射到_other中文本身没有单复数概念但因为两个后缀都定义了无论count是多少都能输出正确内容。上下文参数用来处理性别、状态这类需要不同措辞的场景。比如t(roleLabel, { context: isAdmin ? admin : user })JSON里对应{ roleLabel_admin: 管理员, roleLabel_user: 普通用户 }i18next会优先查找带_admin后缀的Key找不到再回退到没有后缀的roleLabel。这套机制对手写方案来说要自己造轮子i18next直接内置了。4.3 带HTML标签的文案Trans组件怎么用如果文案里有加粗、链接、换行直接用t方法会返回字符串标签会被当成纯文本展示。这时候要用react-i18next的Trans组件import { Trans } from react-i18next function WelcomeTip() { return ( p Trans i18nKeywelcomeTip values{{ name: userName }} components{{ strong: strong / }} / /p ) }JSON里这样写{ welcomeTip: 欢迎 strong{{name}}/strong 回到系统点击 }components里的strong会替换文案中的strong标签最终React渲染出来的是真实的DOM节点而不是被转义的字符串。以前有人图省事直接用dangerouslySetInnerHTML拼接HTML这在用户输入可控时还行一旦文案来自第三方翻译平台就是XSS隐患。用Trans是更安全的处理方式。5. 躲开那些到了生产环境才爆发的坑多语言的很多问题在开发环境看不出来因为语言文件都在本地、网络延迟低、浏览器缓存里一应俱全。到了生产环境异步加载、首屏闪白、Key缺失这些问题就会集中冒出来。5.1 异步加载语言包引发的内容跳变如果用了i18next-http-backend语言包是通过HTTP请求加载的。用户从中文切成英文时组件先渲染中文英文包回来后再重渲染成英文这个短暂过程会让页面内容跳一下。尤其是表格页用户还没看清内容整张表已经换了语言体验很差。我的做法是切换语言时永远不要先切换变量再默默加载而是让切换动作异步等待const handleChangeLang async (lng) { await i18n.changeLanguage(lng) // 到这里已经能确定新语言的所有Key都加载完成了 }另外生产环境应优先把核心语言包作为构建产物的一部分而不是依赖运行时请求。具体来说就是第3.1节那种直接import JSON的方式或者按命名空间动态import。按需加载适合超大语言包但首屏加载的核心文案必须同步存在。5.2 首屏闪白和默认语言设置闪白的本质是用户浏览器里已经存了英文语言偏好但初始化时React先按中文渲染了一帧随后才检测到localStorage并切换成英文。这一帧虽然短但在低端设备上非常明显。解决办法有三个关键点。第一LanguageDetector的order把localStorage排在navigator前面让它先读用户记忆。第二语言包要同步加载确保检测到语言后资源直接可用。第三如果初始化是异步的可以在init完成后才渲染React应用import i18n from ./i18n async function bootstrap() { if (!i18n.isInitialized) { await new Promise((resolve) { i18n.on(initialized, resolve) }) } const { renderApp } await import(./renderApp) renderApp() } bootstrap()如果你的React项目是SSR或预渲染架构还需要处理服务端和客户端的语言一致性这里不展开但核心原则是首屏语言必须是确定的不能先兜底再跳变。5.3 key命名规范团队协作里的隐藏效率点多语言项目维护到后期最大的成本不是写代码而是找Key、改Key、删Key。我见过几种典型乱象同一个保存有人写成save有人写成btnSave有人写成common.save还有的人把业务文案放进common导致公共语言包越来越大。目前我自己比较稳定的规则是小于等于二处使用且属于具体业务语义的文案放对应业务命名空间至少在两个业务域里出现且语义一致的放commonJSON内部按层级组织但层级最多到三层Key的命名用业务对象或页面语义开头例如order.list.title不要用form.title这种谁都看不懂的泛化Key。开发模式下还可以打开i18next的debugdebug: true这样缺失Key时会直接在控制台打印警告。有些团队还会接翻译管理平台Key由平台统一生成代码里自动同步这是更省心的方向但前提是Key规范先定下来。6. 维护半年后我给自己的三条约定多语言功能上线只是开始真正考验人的是之后每个月都有人加文案。我把维护半年后沉淀下来的经验写在这算是给未来的自己也给同行一个参考。6.1 文案改动不直接进代码先进翻译平台没有翻译平台时至少保证所有文案都集中存放在src/locales下的JSON文件里任何人不准在组件里写死中文。改了文案后必须同步更新所有语言文件。曾有一次团队临时改了个按钮文字只改了中文JSON和组件英文包三个月没同步最终在客户现场被指出提示信息还是英文老文案。为了避免这种问题Git提交记录里要求提交信息带上语言包变更前缀代码评审时重点检查中英文资源是否成对变更。6.2 每次加语言先跑一遍回归清单新增语言不是复制粘贴JSON那么简单。我的回归清单是语言切换按钮是否正常工作document.documentElement.lang是否更新日期时间格式是否跟随新语言变化带复数后缀的文案是否都补了对应后缀页面标题和meta描述是否独立维护localStorage里用户选择的语言切换后重新进入页面是否生效缺失Key时fallback是否回退到中文而不是显示裸Key。这张清单一开始只是我手工记在备忘录里后来直接变成了团队提交前的自检项。6.3 格式化函数统一放进i18n配置不要散落在业务组件日期、数字、货币的本地化很多人会直接写toLocaleString()但业务组件里各写各的格式很容易不一致。我的做法是在i18next初始化时注册统一的格式化函数import i18n from i18next const formatValue (value, format, lng) { if (format shortDate value instanceof Date) { return new Intl.DateTimeFormat(lng, { dateStyle: short }).format(value) } if (format mediumDate value instanceof Date) { return new Intl.DateTimeFormat(lng, { dateStyle: medium }).format(value) } return value } i18n.init({ interpolation: { format: formatValue, escapeValue: false, }, })翻译文件里直接写格式化标记{ orderCreatedAt: 订单创建于 {{date, shortDate}} }这样所有的日期显示规则都集中在i18n配置里业务组件只负责传Date对象切换语言后格式自然跟着变。对了还有一件容易忽略的小事我习惯在每次切换语言后顺手更新页面标题和html的lang属性。屏幕阅读器和搜索引擎都会读这个属性。很多项目的多语言功能上线很久document.documentElement.lang还是默认值这属于功能做了但没做完整。如果你也在做React多语言不妨把这一条加进验收清单里。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑