
很多人一上来就把国际化i18n当成一个“挂个插件、存个JSON、调个API”的体力活结果项目还没到中后期翻译文件先爆炸了。有的团队选择了集中式最后几个人同时改一个大文件冲突从早到晚有的团队选择了分散在每个组件里结果语言包到处飞视觉稿里的文案乱成一锅粥。Per-Component每组件与Centralized集中式i18n这个选择属于那种“选之前觉得都行选完之后想重构回去”的典型技术债来源。这篇文章我就从实际项目出发把两种方案的底层逻辑、适用场景、落地细节和踩坑记录一次讲清楚。如果你手头正要做i18n方案选型或者已经被现有方案的高维护成本搞得头疼这篇文章会给你一个绕开坑的完整参考。1. 两种i18n模式的整体设计与思路拆解1.1 集中式Centralizedi18n的核心逻辑集中式i18n很好理解项目里所有文案都收拢到一个或少数几个统一的地方通常是locale/目录下的若干个JSON文件。比如zh-CN.json、en-US.json业务组件里通过t(common.confirm)、t(order.detail.title)这种带路径的key去翻译。这种模式是前端最早的国际化标准做法配合i18next、vue-i18n这类库的开箱即用特性几乎不需要动脑。集中式的本质是“文案资源作为全局单例状态”它把语言资源从组件中剥离从而保证任何组件在任何时刻都能拿到全局翻译。这种模式的核心稳定性来自“引用路径”对“资源文件逻辑结构”的绝对依赖当组件代码里写出t(cart.total)时你心里默认cart这个命名空间一定存在并且一定在全局文件中维护。典型项目里集中式文件长这样{ common: { confirm: 确认, cancel: 取消, save: 保存 }, order: { list: { title: 订单列表, empty: 暂无订单 }, detail: { title: 订单详情 } } }这种组织方式最大的优点在审计和维护——你要改一句文案只需要全局搜一个路径清楚知道它在哪些地方被引用。在设计系统的全局统一性上集中式i18n是碾压性的。它避免了一个意思在三个组件里有三种翻法这种“自然漂移”问题因为所有文案都挤在同一堵墙里大家看的都是同一份。但它的问题也很明显。第一个是逻辑爆炸当项目超过几十个页面时一个zh-CN.json文件会膨胀到上千行。上千行的JSON文件每次团队多人并行开发时合并冲突几乎是常事。第二个是上下文割裂你在写一个订单组件的confirmDeleteOrder文案时必须切换到全局文件找到订单命名空间然后小心翼翼修改。这与“组件即模块”的前端思维完全相反。1.2 每组件Per-Componenti18n的核心逻辑Per-Component模式和集中式刚好对立它把翻译资源直接放在组件或模块目录下。典型结构长这样src/ components/ OrderCard/ index.tsx locale/ zh-CN.ts en-US.ts UserProfile/ index.tsx locale/ zh-CN.ts en-US.ts每个组件自己“带粮”不再依赖全局文件。翻译资源是模块本身不可分割的一部分组件被复用、删除、迁移时它的翻译跟着一起走。这种模式天然契合微前端、独立仓库、多包管理的场景也让“代码审查只管当前改动自己”成为现实。但Per-Component并非把JSON文件一拆就完事它需要配套的命名空间隔离机制。以i18next为例当你注册一个组件的语言包时必须给它一个唯一的命名空间前缀通常就是组件名。否则多个组件各自的save、cancelkey一旦合并依然会造成覆盖。做过这方面落地的人应该很熟悉这样的代码import { useTranslation } from react-i18next; // 每个组件内声明自己的命名空间 const { t } useTranslation(OrderCard);这里的OrderCard既是组件名又是命名空间名。如果组件是OrderCard但命名空间写成了Order短期没感觉长期就会造成资源重复和查找混乱。命名空间是Per-Component的灵魂文件拆得再散命名空间对不上最后还是集中式那些key冲突问题换个位置继续演。1.3 两种方案的决策分界线这两种方案不存在绝对的“谁替代谁”真正的分界线在于业务形态。如果你做的是一套相对封闭的巨石应用Monolith团队规模10人内页面数30以内集中式最合适。它有最低的实现成本也没有不可控的维护坑。反过来如果你做的是平台级中后台、多个业务线并行、组件库需要跨项目复用、甚至涉及微前端或多仓协作Per-Component才是能活到后期的方案。它牺牲了一些统一性换来了模块自治和团队并行开发时不打架。在后面的章节里我会详细展示两种方案在同一套业务下的落地代码、迁移过程和性能表现以及那些只有实操过才会明白的暗坑。2. 核心实现细节与配置方法2.1 集中式i18n的关键配置与工作流集中式方案我以vue-i18n为例来拆解因为vue项目里的集中式是最常见、也最容易走偏的。基础配置通常是在入口文件中注册i18n实例import { createApp } from vue; import { createI18n } from vue-i18n; import zhCN from ./locale/zh-CN; import enUS from ./locale/en-US; const i18n createI18n({ legacy: false, locale: zh-CN, fallbackLocale: en-US, messages: { zh-CN: zhCN, en-US: enUS } }); const app createApp(App); app.use(i18n);到这里为止都还正常真正开始烂是在资源文件扩展之后。第一层坑是资源文件的命名空间设计。很多团队一开始不规划命名空间文案全部摊平写。过三个月就会长成这样{ 保存: 保存, 确认删除: 确认删除, 订单列表标题: 订单列表标题, 订单列表为空: 暂无订单, 订单详情: 订单详情, ... }这种文件不是不能运行而是没法维护。正确做法是进入开发的第一天就按照“模块名/页面名/语义动作”三层设计命名空间。两层太浅容易冲突四层太深写起来累。比如订单模块删除操作{ order: { list: { actions: { delete: 删除, deleteConfirm: 确认删除该订单删除后不可恢复。 }, empty: 暂无订单 } } }组件内引用路径t(order.list.actions.deleteConfirm)路径长是长了点但全局唯一搜索精准不会撞车。这一步做扎实了集中式方案能撑到项目很后期。2.2 集中式方案的增强自动生成类型约束集中式方案最大的隐患是key拼写错误。t(order.list.actions.deleteComfirm)少一个字母运行时直接渲染出key名本身用户看到的就是一串莫名其妙的英文夹杂点号。写错key在编译期完全无法感知到了测试阶段靠肉眼抓。多数团队会忍——然后线上就出过“按钮显示成order.list.actions.confirm”的低级事故。这是集中式方案里我最想强调的改进为翻译资源生成TypeScript类型。做法很简单利用i18next的CustomTypeOptions或者vue-i18n配合tsc自动生成// i18n.d.ts import vue-i18n; import zhCN from ./locale/zh-CN; declare module vue-i18n { export interface DefineLocaleMessage extends zhCN {} } // 组件内 t(order.list.actions.deleteConfirm) // 合法 t(order.list.actions.deleteComfirm) // TypeScript 直接报错这样写错key在保存的那一秒就会被IDE红线标出来而不是等到测试提bug。这个改进几乎零成本却能把集中式方案的维护体验从“心惊胆战”提升到“基本放心”。强烈建议所有走集中式方案的团队把它列为必做项。2.3 Per-Component i18n的落地配置Per-Component方案我们以react-i18next为例因为它对命名空间隔离的支持在所有框架里是最成熟的。核心逻辑是先创建一个不带任何资源的i18n实例然后在每个组件内部动态注册自己的翻译资源。公共初始化文件// i18n.ts import i18n from i18next; import { initReactI18next } from react-i18next; i18n.use(initReactI18next).init({ fallbackLng: en, ns: [], defaultNS: translation, interpolation: { escapeValue: false }, react: { useSuspense: true } }); export default i18n;注意这里的ns是空数组因为所有命名空间都交给了组件自己去注册。然后在OrderCard组件内部追加语言包// OrderCard.tsx import { useTranslation } from react-i18next; import i18n from ../../i18n; import zhCN from ./locale/zh-CN; import enUS from ./locale/en-US; i18n.addResourceBundle(zh-CN, OrderCard, zhCN); i18n.addResourceBundle(en-US, OrderCard, enUS); export default function OrderCard() { const { t } useTranslation(OrderCard); return ( div classNameorder-card h2{t(title)}/h2 p{t(description)}/p button{t(action.delete)}/button /div ); }组件的语言包文件// locale/zh-CN.ts export default { title: 订单卡片, description: 这里是订单的简单描述, action.delete: 删除订单 };关键在于addResourceBundle的第二个参数必须和useTranslation的命名空间参数完全一致否则组件渲染时会找不到资源。而这两个值哪怕差一个字母运行时不报错只会显示key原文排查起来很痛苦。我自己经历过一次把OrderCard写成OrderCardBeta然后整个页面全部变成key名的崩溃现场整整消耗了一个下午。2.4 Per-Component的懒加载与代码分割Per-Component模式还有一个天然优势是支持懒加载翻译资源。在集中式方案里首屏加载时你必须把整个zh-CN.json或en-US.json全量载入文件大了以后首屏的JS体积就多出几百KB的纯翻译文本。Per-Component模式可以在组件被路由懒加载时把语言包随着组件chunk一起加载首屏只加载当前路由组件自己的翻译。在React Router的loadable或lazy中配合使用import { lazy, Suspense } from react; import Loading from ./Loading; const OrderList lazy(() import(./pages/OrderList)); export default function App() { return ( Suspense fallback{Loading /} Routes Route path/orders element{OrderList /} / /Routes /Suspense ); }OrderList组件内部在自己的模块文件里注册语言包。由于整个OrderList含locale文件都被代码分割成独立chunk用户只有真正进入订单列表页时才会下载对应的翻译资源。项目语言包总量很大时这种分割对首屏性能改善非常明显。2.5 两种模式的工具链配合差异最后一层差异在工具链上。集中式模式适配任何构建工具因为本质上就是几个静态JSON文件。Per-Component模式则需要额外注意构建配置。使用webpack时addResourceBundle在模块顶层执行这要求locale文件的代码必须是ESM导出并且不能有循环依赖。如果有组件A引用了组件B的语言包构建时会瞬间变成循环依赖麻烦立刻上线。按我个人经验Per-Component的locale文件尽量写成纯数据对象绝不要在其中做任何计算逻辑或引用其他命名空间的内容。纯数据只做export default这样构建工具才能安全做tree-shaking和代码分割。只要你敢在locale文件里写一个import { common } from ../common打包体积和时间都会出现不可预期的问题。3. 实操过程与核心环节实现3.1 一个集中式项目的真实拆解过程为了把这个话题讲透我用一个真实的中后台项目来演示两种方案的横向对比。假设我们有一个订单管理系统包含“订单列表、订单详情、用户信息、商品管理”四个核心模块页面总数在20个左右。这个项目最初选择了集中式方案我们来完整过一遍落地方式。第一步是建立目录结构。即使选了集中式我仍然建议把不同业务模块的文案拆分到不同文件里再统一合并。这样虽然最后使用的时候是一个全局对象但源文件可以被团队平行修改src/ locales/ zh-CN/ index.ts common.ts order.ts user.ts product.ts en-US/ index.ts common.ts order.ts user.ts product.ts第二步是把各模块JSON用merge合并成完整messages// zh-CN/index.ts import common from ./common; import order from ./order; import user from ./user; import product from ./product; export default { common, order, user, product };第三步是组件内的引用方式。这步最重要的规范是禁止在组件内拼接key。很多团队会写出t(order. type .title)这种动态拼接一旦type不匹配渲染出的就是原始路径。正确做法是把所有动态key改成静态路径加参数的形式// 错误 const key order.${type}.title; t(key); // 正确 t(order.title, { type });同时配合前面提到的TypeScript类型约束绝大部分key错误在写代码阶段就被拦截了。这个项目在走完这三步后整体体验处于“能用但你必须时刻记得规范”的状态。直到后面支持了多个业务线每一个模块的文案都在同一个文件里膨胀并行开发时git冲突开始频繁出现才让我下定决心迁移到Per-Component。3.2 从集中式迁移到Per-Component的实操路径真实的迁移过程不只改代码更像是一次数据搬家和模块解耦。我把整个过程分成三步每一步都有对应的验证方式和回滚机制。第一步是边界抽屉。把所有全局翻译资源按页面/组件维度重新归属。做法很简单定义一个迁移清单原路径归属组件新命名空间order.list.titleOrderListOrderListorder.list.actions.deleteOrderCardOrderCarduser.profile.avatarUserProfileUserProfile在迁移初期不用一口气全部拆完而是按页面粒度逐个推进。比如先拆OrderList组件和它依赖的子文案其他仍走全局。这个阶段两者的翻译数据会重复需要向团队传达一个原则新代码优先引用组件自身命名空间旧引用继续走全局直到全局文件里对应的key被清空为止。第二步是资源重挂载。在每个组件的入口文件顶部把该组件需要的翻译资源通过addResourceBundle挂到i18n实例上。迁移过程中最容易被忽略的是那些被多个组件共享的通用文案比如“保存”“取消”“确认”。这些不能直接塞进某个业务组件里需要一个独立的Common命名空间// src/components/Common/locale/zh-CN.ts export default { save: 保存, cancel: 取消, confirm: 确认, delete: 删除 };然后每个用到这些通用文案的组件在自己的资源文件里引用Common的key不行Per-Component模式里禁止跨组件引用翻译资源。正确处理方式是组件内部写useTranslation([OrderCard, Common])也就是说一个组件可以同时关联多个命名空间但每个命名空间的资源必须由该命名空间自己注册。这样既保留了通用性又避免了集中式那种全局大锅饭。第三步是验证期与回滚期。每迁移完一个组件要检查三件事组件内是否出现了硬编码的字符串、全局文件里是否还残留该组件的key、以及切换语言后组件内所有文案是否正常切换。检查通过后删掉全局文件里对应的key一个组件就算物理隔离完成。3.3 迁移过程中的真实数字表现这套迁移过程在一个实际的上线项目中我用两个规模相同的页面做了对比。集中式方案中全局翻译文件达到2400行JSON单个语言包体积约180KB含格式化空白编译后首屏需要全量加载这180KB。迁移到Per-Component后每个组件的语言包平均只有15-40KB页面首屏只需要加载当前组件语言包。更直观的收益在并行开发。集中式阶段三个后端同学同时改同一个订单模块的翻译文件几乎每天都有冲突。迁移完成后OrderList和OrderCard的翻译文件彼此独立三周内零冲突。当然也要说实话Per-Component迁移完成后全局搜索文案的能力变弱了。以前一句“这是订单备注”在全局文件里CtrlF一下就知道哪些页面用了。拆分之后你得先确定它在哪个组件里再进入对应locale文件搜索。所以翻译资源的搜索方式从“全局搜索”变成了“组件定位局部搜索”这是一个真实存在的体验降级点。团队需要养成的习惯是通过命名空间和组件结构推理文案位置而不是靠文件搜索一网打尽。4. 常见问题与排查技巧实录4.1 语言包加载失败却不报错的casePer-Component模式最常见的诡异bug是页面正常渲染但所有文案都是key名本身不报任何运行时错误。这个现象的根本原因是useTranslation中指定的命名空间没有在当期i18n实例中找到对应的资源。排查时按这个顺序走。第一步确认addResourceBundle在组件模块顶层是否执行成功。这步最容易踩的坑是组件被React.lazy懒加载时资源注册代码被一起延迟执行而Navigate或者路由前置逻辑提前调用了t。解决办法是把addResourceBundle的调用移到模块顶层而不是放在组件函数体或者useEffect里。第二步检查资源是否被同名命名空间覆盖。如果项目中存在两个组件不小心注册了同一个命名空间先加载的那个会被后加载的整个覆盖。比如UserProfile和OrderCard同时注册了名为common的命名空间那么谁后加载谁生效另一个组件的t(save)就会渲染成save。这个问题在早期很难发现因为两个文件本地测试都正常合并运行时才暴露。所以Per-Component模式必须建立命名空间审计机制。我习惯用一个脚本定期扫描所有useTranslation和addResourceBundle的首参数确保每个命名空间只被一个组件声明使用。4.2 语言切换时页面文本不刷新另一种高频踩坑场景是语言切换后页面文本没有实时更新。Per-Component模式下addResourceBundle是即时生效的但你如果用了类组件且没有订阅language变化的hooks组件里的文案就不会重新渲染。类组件需要额外绑定i18n的事件监听函数组件只要你用了useTranslation并传了正确的命名空间理论上会自动更新。真正的更新不及时多数出在资源挂载晚于渲染时机的场景。比如你进入页面组件刚挂载时语言包还没注册完成第一帧渲染把key当文案显示了出来等资源注册完成后虽然触发了react-i18next的强制更新但如果你使用的是最新稳定版本这个更新机制是可靠的。老版本v11之前存在已知的更新竞态问题升级库版本比写一堆useEffect补丁靠谱得多。所以别急着写“强制刷新组件”这种脏代码先确认版本再排查挂载顺序。4.3 集中式方案中语言包冲突的破解如果你留在集中式方案也不代表就安全了。集中式的典型事故是多人同时编辑一个JSON文件merge的时候把另一个人的翻译覆盖了。git冲突至少还能看到冲突标记最怕的是无声覆盖——一个人在合并时手滑删了一把key另外一个人不留意提交之后就再也找不到那几句文案了。我的建议是给集中式语言文件配一个简单的静态检查脚本每次提交前跑一遍检查所有语言文件的结构同构性。也就是zh-CN.json和en-US.json必须有完全一样的key集合多一个、少一个都视为异常node scripts/check-locales.js脚本核心逻辑很简单const zhKeys Object.keys(zhCN); const enKeys Object.keys(enUS); const onlyZh zhKeys.filter(k !enKeys.includes(k)); const onlyEn enKeys.filter(k !zhKeys.includes(k)); if (onlyZh.length || onlyEn.length) { console.error(语言文件key数量不一致); console.error(只在zh-CN中存在的key:, onlyZh); console.error(只在en-US中存在的key:, onlyEn); process.exit(1); }这套脚本逻辑简单但放在CI里执行能保证语言文件结构永远统一极大减少“某个语言少了文案”这类问题。像这种工具不是核心业务但是维护体验的分水岭。没有它集中式方案在团队稍大时就是灾难有了它集中式方案的维护成本会立刻降到一个可持续的水平。4.4 表格汇总集中式与Per-Component的问题对照速查问题集中式Per-Componentkey拼写错误运行时才显示key名可加类型约束同上也需类型约束团队并行冲突高频需要锁定文件或拆分模块文件低频组件间互不干扰文案全局搜索方便一个文件CtrlF即可不方便先定位组件再搜索语言切换不刷新偶发通常与事件订阅有关偶发多数是资源挂载晚首屏体积全量加载所有语言包可配合代码分割实现按需加载微前端跨应用复用困难语言包需要跟着走自然适配语言包随组件走设计统一性高全局统一管理低每个组件自己管自己4.5 关于统一文案风格的两个实战建议不管选哪种方案最后补两个和“模式选择”无关但所有i18n项目都需要注意的实操建议。第一把“产品术语表”写进代码注释里。例如订单模块的“待支付”和“未支付”对用户来说都是“没付钱”但产品上可能分为两个不同状态。这两个文案会出现在不同组件里Per-Component模式下管理员要维护两份集中式模式下也可能被不同开发同学写成两套key。解决方案是在每个locale文件头部放一个术语注释块标明哪些词不能随意更改、哪些词在不同场景必须使用不同翻法。第二把语言包文件的翻译流程独立于开发流程。翻译稿应该是给专业翻译人员看的而不是让开发员在代码里顺手填几个英文或中文就完事。无论是集中式还是Per-Component都要在代码审查中检查语言文案本身的翻译质量。开发同学负责保证资源加载正确、key路径正确翻译内容本身的事交给翻译人员或者产品经理确认。这个权限分割做好后文案质量的稳定性才能有保障。5. 切换语言时的补充细节与持久化策略5.1 语言状态持久化的通用处理无论你最终选择哪种i18n模式语言切换的持久化都是绕不开的细节。最常见的做法是把语言偏好写入localStorage然后在应用初始化时读取并设置。思路是对的但有几个容易踩的细节要说明清楚。第一是初始化时机。如果你在页面已经完成首帧渲染之后才异步读取localStorage再切换语言用户会肉眼看到一次“英文闪一下变中文”的闪烁。推荐的做法是在应用入口的最早位置同步读取// main.ts const savedLocale localStorage.getItem(app-locale) || zh-CN; i18n.changeLanguage(savedLocale);这里使用了浏览器API不会阻塞太多性能换来的是首帧渲染就是正确的语言。在React中如果你用useState初始化语言同样建议用惰性初始化函数同步读取避免异步闪烁。第二是语言切换后URL是否需要同步。对于SEO要求不高的中后台只需要localStorage即可。对于需要SEO友好的官网/前台页面语言状态要同步到URL路径如/en/order、/zh/order这种情况下语言解析优先顺序应该是URL参数 localStorage 浏览器默认语言。这个优先顺序如果颠倒就会出现用户从英文链接进入却看到中文页面的问题。5.2 日期、货币、复数的本地化i18n不只是翻译字符串还包括数字格式、日期格式、货币符号、复数规则。这些往往被当成“后续再加”结果越拖越乱最后成了技术债的一部分。在集中式方案中日期和货币一般通过Intl对象全局处理const dateFormatter new Intl.DateTimeFormat(zh-CN, { year: numeric, month: long, day: numeric });在Per-Component模式下每个组件自己决定展示格式就会出现一个订单列表里的日期格式和订单详情里的日期格式完全不一样的混乱。我的建议是把日期和货币的统一格式化单独抽成一个独立工具模块放在所有组件之外不要用i18n的messenger机制处理——它本质上是纯工具逻辑不是翻译资源。复数规则是另一个明显差距。中文没有单复数英文有单复数。如果你做海外市场Per-Component模式下最容易出现的坑是开发同学忘了区分复数英文文案总写单数形式。i18next和vue-i18n都内置了复数支持用法是t(itemCount, { count: orders.length });语言文件里必须同时给出单数形式和复数形式{ itemCount_one: {{count}} item, itemCount_other: {{count}} items }中文则只需要{ itemCount: {{count}} 项 }这里唯一要注意的是Per-Component模式下每组语言都带自己的复数形式你在组件内部写的时候必须清楚记得英文有单复数区别。否则代码写成了t(itemCount, { count: 1 })却永远只看到“items”的形式这个问题后期很难察觉因为业务逻辑上单数场景可能很少出现。6. 个人经验收尾选型时最容易被忽视的两个问题做国际化方案选型时大多数人都在比技术特性但我真正想多说一句的是方案能不能长期存活取决于团队对新人的友好程度。集中式方案里新同学很快能学会“所有文案都放一个目录下”上手快但等文件膨胀到2000行时新人第一次改全局文件大概率会紧张因为他们生怕改坏别人的key。Per-Component模式相反新人要看懂组件的内部结构后才能找到语言包上手成本稍高但一旦理解了“组件自带翻译”这个思路就很少再犯全局污染的错误。团队的新人比例、培训成本其实比技术选型本身更影响方案寿命。第二点是如果你正在做组件库Per-Component几乎是唯一合理的方向。组件库的使命就是跨项目复用你不能要求每个接入方按照你的全局文件组织方式去维护语言包。组件的语言包必须跟着组件本身走这是组件库国际化的最低门槛。但如果你的目标是一个业务系统内部应用而且没有组件复用计划集中式依然是性价比最高的方案。硬上Per-Component会平白增加复杂度收益却并不明显。我在实际项目里看到的绝大多数i18n问题根源都不是选型错误而是团队在选定方案之后没有把配套规范落实到位。集中式配一套结构校验脚本加类型约束Per-Component配一个命名空间审计脚本加模块自治规范两种方案都能跑得很顺。反过来不管选了哪边不建立规范最终都会在项目中期变成一处难以下手的泥潭。