☰
前端国际化方案选型:集中式与组件级的架构权衡与实践
2026/10/3 14:57:11 网站建设 项目流程

做前端国际化的这些年,我见过太多团队在Per-Component和Centralized两种方案之间反复横跳。有人觉得集中式管理一劳永逸,结果项目大到一定程度后被一锅端的JSON文件压垮;也有人一开始就搞组件级文案,最后发现复用和一致性根本管不住。如果你正站在这个岔路口纠结,这篇东西应该能帮你省下不少试错成本。

先说结论:这不是一个“哪个更好”的问题,而是一个“你的项目处在什么阶段、团队怎么协作、产品怎么迭代”的问题。把这两种方案的本质逻辑、适用边界和常见的坑搞清楚,你才能做出真正适合自己的选择。

1. 先拆解两种方案的核心思路

在聊具体实现之前,得先把Per-Component和Centralized到底在解决什么问题说清楚。很多讨论一上来就扎进代码细节,反而把最关键的架构意图丢了。

1.1 Centralized i18n的统一管理逻辑

Centralized i18n,顾名思义,就是把项目里所有的翻译文案集中到一个或几个统一的资源文件里管理。这是最传统、也最容易被团队接受的方式。典型的结构是这样的:

// src/locales/zh-CN.json { "common": { "confirm": "确定", "cancel": "取消", "save": "保存" }, "login": { "title": "欢迎回来", "username": "用户名", "password": "密码", "submit": "登录" }, "dashboard": { "welcome": "欢迎,{name}", "stats": "本月新增 {count} 个用户" } }

组件里的使用方式也直白,通过t函数全局访问:

import { useTranslation } from 'react-i18next'; export function LoginForm() { const { t } = useTranslation(); return ( <div> <h1>{t('login.title')}</h1> <input placeholder={t('login.username')} /> <button>{t('login.submit')}</button> </div> ); }

这套方案的底层逻辑是:文案是“全局资源”,理应由一个统一的地方管理和维护。它的好处非常明显——你可以一眼看全整个项目所有语言的文案,翻译协作、批量替换、全局搜索都非常方便。对于工具链(比如翻译管理平台、自动化校验脚本)来说,集中式的文件结构也天然友好。

我早期做的一个后台管理项目,大概有80多个页面,当时用的就是Centralized方案。文案文件按功能模块分成了十几个子文件,再通过命名空间合并。初期的体验确实不错,新页面要加文案,直接打开对应的JSON文件往里面塞键值对就行。

1.2 Per-Component i18n的自治思想

Per-Component方案则是另一个思路:每个组件自己携带自己的文案,不搞全局资源文件。Vue生态里比较有代表性的是Vue I18N的组件选项,React生态里也有类似的设计。它长这样:

<template> <div> <h1>{{ $t('title') }}</h1> <p>{{ $t('description') }}</p> </div> </template> <script> export default { name: 'ProductCard', i18n: { messages: { 'zh-CN': { title: '产品卡片', description: '这是一个产品描述' }, 'en-US': { title: 'Product Card', description: 'This is a product description' } } } } </script>

React生态里类似的做法是把文案定义在组件文件旁边,比如每个组件目录下放一个locale.ts:

// components/ProductCard/locale.ts export const productCardMessages = { 'zh-CN': { title: '产品卡片', description: '这是一个产品描述' }, 'en-US': { title: 'Product Card', description: 'This is a product description' } } as const;

组件里使用时直接引用本地定义,不再依赖全局的key路径。

Per-Component的核心逻辑是:组件是自包含的单元,文案属于组件内部实现细节,应该跟组件一起移动、一起销毁、一起复用。这个思路跟CSS的CSS Modules理念如出一辙——把样式从全局作用域中隔离出来,文案也是一样的道理。

这种方案对于那些需要跨项目复用的组件库尤其有吸引力。一个公共组件(比如日期选择器、富文本编辑器)带着自己的文案独立发布,使用方不需要额外为它配置任何翻译资源,装完就能跑。我在做内部组件库的时候深刻体会到这个优势——组件库发版时只要带上自身的locale资源,下游项目完全不需要知道组件内部有哪些文本。不过这也带来一个直接问题:项目整体的文案分散在各个组件里,你想快速审视全局所有文案,基本做不到。

2. 两种方案的核心优劣对比

很多文章喜欢把两种方案做成一张对比表,然后说“根据你的情况选择”,这当然没问题,但只看表格很难理解背后的权衡逻辑。我用实际场景来说透。

2.1 Centralized的管理优势与维护瓶颈

Centralized的管理优势,往深了说其实是“可见性”和“可控性”。

先谈可见性。产品经理、运营、翻译人员不需要懂代码,他们只需要看那几个JSON文件就能了解到整个系统的文案情况。这在涉及多语言版本规划时特别重要——比如你们要上线日语版,市场部需要提前知道哪些文案还没翻译。集中式的资源文件可以直接导出成Excel给到翻译公司,做完再导入回系统。这个工作流在Per-Component方案下会痛苦很多,你得写脚本遍历所有组件文件,再拼装成一个可交付的清单。

再谈可控性。集中式的文案可以进行统一的规范约束,比如统一时间格式的处理、统一称呼的翻译口径、统一术语表。像“确定”和“取消”这类高频词,如果分散到各个组件里,很容易出现十种不同的写法——"确认""OK""是的""对的"。集中式管理可以从工具层面强制所有团队使用common.confirm这个key,保证一致性。

但Centralized的瓶颈也很现实:文件会随着项目膨胀变得难以维护。到了后期,那堆JSON可能超过几千个key,你根本不知道哪些key还在使用、哪些已经废弃了。每次找文案都要翻半天文件目录,改一个公共文案还要担心影响面。我记得有个项目到了后期,合并请求里经常出现“删了一行文案,构建就红了”的情况,就是因为某些key被隐蔽地引用了。为此我们后来专门写了静态检查脚本来扫描未使用的key,属于被逼出来的方案。

2.2 Per-Component的优点与协作挑战

Per-Component的好处首先是内聚性。每个组件在被复用到新项目时,它的文案随之带过去,不需要在新项目里重新配置一套翻译映射。这对组件库开发者和跨项目协作团队来说体验极佳。

其次是重构和维护的便利。你删掉一个组件,它的文案也随之消失了,不会有残留的无用key堆积在全局文件里。你改文案时,直接在组件内部改,不需要去翻一个遥远的JSON文件,上下文距离更近,出错概率更低。

第三个好处是代码分割友好。在需要按需加载的场景下,Per-Component的文案可以跟随组件代码一起做懒加载,不需要把整个翻译文件都拉下来。对于大型单页应用,这能减少首屏的资源体积。我在一个大型后台系统里实测过,光靠这个特性首屏翻译资源就能少加载200多KB。

但Per-Component的挑战也很扎心。最大的问题是全局一致性失控。同一个“删除”按钮,你可以想象在不同组件里出现"删除""移除""删掉""Remove""Delete"五种叫法的概率有多大。没有中央控制,这种乱象几乎一定会发生。而且当你需要统一全局的翻译口径时,你得逐个组件去改,效率极低。

协作方面也头疼。如果团队里有专门的翻译人员或本地化项目经理,他们通常会希望有一个统一的平台来管理文案。Per-Component方案需要额外写工具来收集和汇总所有散落的文案,不然翻译人员根本无从下手。这意味着你需要一小块工程化的投入来做这件事。

另外还有一个隐性成本是key命名的一致性。Per-Component方案下,同一个组件的文案key在不同时期可能被命名为title、heading、name等等,没有全局的命名规范就很容易混乱。而Centralized方案天然有一个带模块前缀的key体系(如dashboard.welcome),这种结构性的约束能帮助维持秩序。

2.3 量化对比:什么时候选哪种

我把自己的经验整理成一张决策表,但请注意,这只是一个参考,真正落地时还要结合具体的情况再调整。

维度Centralized(集中式)Per-Component(每组件)
项目规模中小型项目、页面数可控大型项目、组件数量多且长期演进
团队协作有专职翻译/本地化人员纯研发团队,文案由开发直接维护
组件复用需求低,组件只在当前项目内部使用高,组件需跨项目复用或发布插件包
首屏性能要求一般高,需要按需加载文案
文案一致性要求高,需要全局统一口径中,允许组件内部自治
工程化成熟度要求低,工具链简单要求高,需要额外编写收集汇总脚本
重构频率中低高,组件频繁增删

如果你是刚开始做国际化的新项目,我通常会建议先上Centralized。原因很简单:它能让团队快速建立对国际化的认知,理清文案的规范,而且初期规模小、维护成本低。等项目的组件规模上来了、跨项目复用需求出现了,再考虑逐步迁移到Per-Component或混合方案。不要一开始就上Per-Component,那个自治性带来的自由,在没有规范约束的团队里大概率会变成混乱。

3. 实操层面:两种方案的落地实现

纸上谈兵聊完了,接下来聊聊具体怎么落地。这里我会给出一些完整的实现细节和配置说明,尽量让读者可以直接照着做。

3.1 基于react-i18next的Centralized工程实现

React生态里最主流的选择是react-i18next配合i18next,这套组合成熟、文档全、插件生态也很完善。基本的路数是初始化一个全局的i18n实例:

// i18n.ts import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import zhCN from './locales/zh-CN.json'; import enUS from './locales/en-US.json'; i18n.use(initReactI18next).init({ resources: { 'zh-CN': { translation: zhCN }, 'en-US': { translation: enUS } }, lng: 'zh-CN', // 默认语言 fallbackLng: 'en-US', // 兜底语言 interpolation: { escapeValue: false // React已经处理了XSS,不需要再转义 }, returnNull: false // 翻译缺失时不返回null,避免组件报错 });

然后在组件里使用useTranslation钩子即可。需要注意几个关键配置项:fallbackLng最好设置,否则某个key漏翻译会直接页面白屏(取决于你的用法);returnNull建议设为false,这样拿不到翻译时至少返回一个空字符串而不是null。我踩过这个坑,当时某个新同事写的文案key拼错了,页面一直渲染出空的tooltip,排查了半天才发现是返回了null而不是缺省值。

这个方案在工程化上要做的事情还包括:命名空间拆分、按语言分包、懒加载。比如你可以把不同的模块拆成不同的namespace,然后用useTranslation('dashboard')显式指定。资源文件还可以放到一个独立的npm包里,多个前端项目共享同一套翻译资源,这对中大型公司来说价值很大。

3.2 Vue I18N与Per-Component的完整实现

Vue 3生态里的vue-i18n对Per-Component的支持比较原生。每个SFC组件里可以定义一个i18n选项:

<script setup> import { useI18n } from 'vue-i18n'; const { t } = useI18n({ useScope: 'local', messages: { 'zh-CN': { title: '本地标题', description: '本地描述文案' }, 'en-US': { title: 'Local Title', description: 'Local description' } } }); </script> <template> <div> <h1>{{ t('title') }}</h1> <p>{{ t('description') }}</p> </div> </template>

关键是那个useScope: 'local',它告诉vue-i18n不要把这个组件的messages合并到全局去,而是只保留在组件作用域内。如果你不写这个选项,默认是global模式,组件定义的messages会跟全局合并,那样反而会造成混淆。

在组合式API风格的组件里,useI18n更灵活,也可以在defineOptions里直接声明i18n。不管哪种方式,核心都是一样的:每个组件拥有自己的messages作用域,t函数在查找时会先查询本地作用域,找不到再去全局资源里找。这个“本地优先、全局兜底”的机制我认为是Per-Component方案里最合理的实现方式——它同时兼顾了本地自治和全局兜底两个需求。

另外如果你做的是Vue组件库,这套方案还能配合全局语言切换的响应式机制。组件库内部的使用者切换语言时,每个组件都会自动响应,不需要额外的事件通知。

3.3 混合方案的架构设计

讲了两种极端方案,现在聊聊大多数真实项目最终会走向的地方——混合方案。这不是和稀泥,而是工程上最务实的选择。

混合方案的核心边界划分是这样的:与业务紧密相关的文案,比如活动文案、营销话术、运营后台的状态提示,放在集中式资源文件里统一管理。这部分文案需要产品、运营、翻译多方协作,统一管理是必须的。而通用型的基础组件文案,比如日期选择器的“今天”“本月”、上传组件的“拖拽文件到此处”、表格的“暂无数据”,这些高度通用、几乎不会随业务变化的文案,则跟随组件走,做成Per-Component的形式。

实现上,混合方案的架构如下:全局初始化时仍然加载集中式的资源,同时保留useI18n.mergeLocaleMessage的入口,支持将组件的messages合并到全局(如果组件希望这样做)。但更优雅的做法是让组件使用useScope: 'local',这样全局资源做兜底、本地资源做精准覆盖,互不干扰。

在React这边,常见的做法是用一个自定义的Hook封装:

// useComponentI18n.ts import { useTranslation } from 'react-i18next'; export function useComponentI18n(componentMessages?: Record<string, any>) { const t = useTranslation().t.bind(null); // 这里做一层代理:优先查找组件本地messages,找不到再走全局t return { t: (key: string, options?: any) => { if (componentMessages && key in componentMessages) { return componentMessages[key]; } return t(key, options); } }; }

当然这只是个简化版本,真正落地时还需要考虑语言切换后的响应式问题、本地messages的key冲突问题。但这些细节恰恰是值得投入时间去打磨的,因为混合方案解决了绝大多数团队的真正痛点:核心业务文案可控,基础组件文案自治。

4. 关键细节与常见问题排查

方案选型定下来之后,落地过程中一定会碰到各种细节问题。我把自己踩过的坑和排查思路整理一下,希望对你有用。

4.1 语言切换后组件不刷新

这是Per-Component方案最常见的坑。由于组件通过useI18n声明的messages只是一个普通的JavaScript对象,在切换语言时如果组件实例没有重新触发渲染,t函数拿到的还是旧语言文案。

解决办法是确保你的全局i18n实例在语言切换时触发响应式更新。在Vue这边,需要把locale做成响应式ref,并且所有使用了useI18n的组件都显式地依赖它:

// localeStore.ts import { ref } from 'vue'; import { i18n } from './i18n'; export const currentLocale = ref('zh-CN'); export function setLocale(locale: string) { currentLocale.value = locale; i18n.global.locale.value = locale; }

在React这边,由于react-i18next已经封装好了,语言切换时Provider会重新下发context,只要你的组件不是React.memo过度优化且memo的比较逻辑没有忽略locale的变化,基本不会有问题。但要注意,如果你在组件里用useMemo缓存了翻译结果,务必将locale作为依赖项。

4.2 key命名冲突与全局污染

Per-Component方案里组件各自定义key,很容易出现两个不同组件用同一个key名但意图完全不同的情况。比如一个组件的title是“标题”,另一个组件的title是“公司名称”。如果混合方案没有做好作用域隔离,这两个key就会互相覆盖。

排查方法很简单:在开发环境打开控制台看警告信息。vue-i18n和react-i18next在发生key冲突时通常会在console打印警告(开启debug模式后)。我建议你在项目初期就开启debug模式:

// vue-i18n i18n.global.setMissingHandler((locale, key) => { console.warn(`[i18n] Missing translation: ${key}`); });

发现冲突后,最彻底的办法是给每个组件的key加上组件名前缀。虽然这看起来违背了Per-Component的简洁初衷,但实际工程里带前缀反而更容易定位问题。比如ProductCard.title比单纯的title清晰得多。

4.3 服务端渲染(SSR)下的特殊考虑

如果你的项目用了Next.js或者Nuxt这类SSR框架,国际化方案还需要额外注意。Per-Component方案在SSR下有一个隐藏的坑:组件的messages需要同时存在于服务端和客户端。如果messages定义在组件内部,SSR时服务端渲染出来的HTML文案是正确的,但客户端Hydration时,如果客户端的messages资源没有及时加载,水合过程会报错或者闪现错误的翻译。

解决思路有两种:一种是把组件级别的messages打成独立的chunk,在SSR的HTML里通过<script>标签预先注入到全局,客户端Hydration时直接读取;另一种是在组件挂载前先确保所有local messages已注册完毕。无论哪种,都需要你在SSR的数据流设计上多花一些功夫。

4.4 翻译缺失时的降级策略

不管是哪种方案,翻译缺失都是一个不可完全避免的情况。特别是新文案加入、翻译还没跟上的时候。一个健壮的降级策略是:

  1. 先查当前语言的精确key
  2. 查fallback语言(比如英语)的对应key
  3. 返回key本身或者一个占位符
  4. 同时在开发环境输出警告,统计缺失情况

这个策略在Centralized方案下很容易实现,因为所有key都在一个地方,可以写一个脚本定期比对每个语言文件的差异。Per-Component方案则需要额外写一个收集脚本,遍历所有组件文件提取messages,再跟全局资源做比对。

写到这里,我不打算做一个“最终应该选哪个”的一刀切结论。从我的经验来看,许多项目最终会走到Centralized和Per-Component共存的混合形态,这不是妥协,而是因为这两种方案各自解决的是不同的核心矛盾。Centralized解决的是“可控和协作”的问题,Per-Component解决的是“内聚和自治”的问题。一个健康的项目通常两者都需要。最后再分享一个小技巧:无论你选择了哪种方案,一定要在项目初期就把国际化的目录结构、key命名规范和检查工具链搭建好。我见过不少项目因为前期图省事没有这些东西,后期再补的成本高到让人想重写。工具链花两三天时间投入,后续每个迭代都能省下成倍的时间。这个投入,非常值得。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询