CodeSpirit 多语言国际化使用指南(Beta)
做国际化这件事,很多人一开始觉得简单,无非就是“把文案抽出来,翻译几份,切换一下”。真到上手才发现,坑全埋在后面:语言包怎么组织才不失控?带参数的消息怎么处理?某个语言的复数规则跟中文完全不一样怎么办?日期格式、货币符号、文字方向……哪一样拎出来都能让项目在临上线前翻车。这篇指南就围绕CodeSpirit项目的国际化实践展开,把从资源文件设计、文案抽取到运行时切换、问题排查的完整链路梳理一遍,所有内容都来自我在实际项目里踩过的坑和验证过的方案,适合正在做国际化改造、或者准备给项目增加多语言支持的开发者参考。
1. 项目启动前先想清楚:语言资源文件的组织方式与命名策略
1.1 资源文件的目录结构怎么定,后续维护才不痛苦
很多团队做国际化,第一步就是建一个locales目录,然后按语言分文件夹。这个方向没错,但颗粒度怎么切,直接决定半年后你维护这个项目时的心情。
我见过最崩溃的写法,是把所有文案全塞进一个messages.js里,一个对象几千行,找一个键靠Ctrl+F来回扫。这种方案在项目只有三五个页面时还能忍,一旦模块变多,冲突和误改就成了家常便饭。
更合理的方式是按业务域拆分。CodeSpirit里我推荐这么组织:
src/ locales/ zh-CN/ common.json auth.json dashboard.json settings.json en-US/ common.json auth.json dashboard.json settings.json这么做的好处显而易见:每个模块的文案独立成文件,多人并行开发时各自改各自的,git冲突的概率大幅下降。而且按模块加载时,可以做到只加载当前路由需要的语言包,而不是一次性加载全部,首屏性能更好。
用JSON而不是JS模块存文案也有讲究。JSON是纯数据,不掺逻辑,后端如果要做翻译管理平台,或者想用在线表格维护文案,导入导出都方便。JS模块虽然能写函数、能用变量,但也容易混进业务逻辑,最后搞不清语言包里到底存的是数据还是代码。
1.2 Key的命名策略:一眼看懂归属,避免“孤儿文案”
Key的命名,是国际化项目里最容易被忽视、后患最大的决策。没有统一规范,翻译的人不知道这个key是哪个页面的,开发的人不知道这个key有没有被引用,最后就是一堆“孤儿文案”躺在语言包里,删也不敢删,留又占地方。
我建议的命名格式是“模块.页面.元素.状态”,比如dashboard.chart.revenueLabel、auth.login.submitButton、settings.profile.avatarUploadSuccess。这套规则有四个明确的优势:
- 可读性好,看到key就知道它属于哪个功能,无需打开页面去对照
- 按前缀就能实现语言包的自动合并和分割,配合构建工具做tree-shaking也方便
- 排序后结构清晰,代码审查时一眼能看出新增了哪些文案
- 工具链可以基于前缀自动生成文档,甚至校验某个模块下是否有漏翻译的key
再补充一点:状态尽量写在key里,比如按钮的加载中状态,是auth.login.submitButtonLoading而不是auth.login.loading。因为loading这个词太泛,放common里会被各种地方引用,改起来牵一发动全身;写在具体业务key下,语义明确,后续删除也无牵挂。
2. 最容易踩坑的五个国际化细节:占位符、复数、日期、货币与排版方向
2.1 占位符别用字符串拼接,请统一采用命名参数
国际化里最经典的低级错误,就是写这样的代码:
// 这样写,翻译的人根本没法调整语序 const msg = "欢迎 " + username + " 登录系统";中文语序下“欢迎XX登录系统”没毛病,但英文习惯是“Welcome, XX”,有些语言甚至会把动词放到句尾。你一旦用了字符串拼接,翻译就只能放弃语法正确性,把整句硬拆成几段。这不是翻译能力问题,是代码结构堵死了人家的路。
正确做法是使用命名占位符。CodeSpirit统一用{name}这种语法:
{ "welcome.message": "欢迎 {username} 登录系统", "welcome.message_en": "Welcome, {username}" }调用时统一走工具函数,不要出现“有的地方用{}、有的地方用%s、有的地方用$1”这种混乱局面。
另外要特别提醒:同一个key下的参数,不同语言版本里必须保持一致。曾有个项目把{count}写成了{num},中文语言包没暴露问题,但英文语言包渲染时直接把{count}当普通文本输出在页面上,用户看到一串花括号。这种事情就是在挑战用户耐心,测试还未必覆盖得到。
2.2 复数形式:不要以为只有英语才区分单复数
中文的复数处理很简单,名词基本不变,靠量词表达数量。所以很多国内团队设计语言包时,压根没考虑过复数键位。等英文包上线,问题就来了。
英文里“1 item”和“5 items”是两回事。而阿拉伯语、俄语这些语言的复数规则更复杂,有的分单数、双数、少数、多数。硬编码count > 1 ? items : item只能cover住英文,遇上别的语言就原形毕露。
如果要手动管理,可以这样设计消息结构:
{ "cart.itemCount": { "one": "1 item", "other": "{count} items" } }但这还远远不够,因为不同语言对“哪类数字算one”的定义不同。所以更可靠的方案是借助Intl.PluralRules这个浏览器原生API:
const pr = new Intl.PluralRules('ru'); pr.select(1); // 'one' pr.select(2); // 'few' pr.select(5); // 'many'在CodeSpirit里,我建议语言包的值先通过一个pluralize工具函数处理,根据当前语言环境自动选择正确的复数形式。这个坑不踩不知道,等海外用户开始反馈“页面上写着1 items”,你就明白什么叫细节决定体验了。
2.3 日期与货币:永远使用Intl API,而不是手写格式
时间格式化是另一个重灾区。“2024年1月5日”在中文环境没问题,但放到英文环境应该是“January 5, 2024”或“2024/01/05”,欧洲很多国家又是“5 January 2024”。你要是自己写正则拼接,基本就把自己锁死在一种格式上了。
浏览器提供了足够强大的原生能力,一定优先使用:
const date = new Date('2024-01-05T10:30:00'); // 根据不同locale自动格式化 new Intl.DateTimeFormat('zh-CN', { dateStyle: 'full' }).format(date); // '2024年1月5日星期五' new Intl.DateTimeFormat('en-US', { dateStyle: 'full' }).format(date); // 'Friday, January 5, 2024'货币也同理:Intl.NumberFormat可以自动处理货币符号、千分位和小数位,甚至不同货币的展示习惯。这点在设计订单金额展示时极其关键,同一个数字1234.5,美元环境显示$1,234.50,欧元区显示1 234,50 €,不是简单换个符号就行。
在CodeSpirit里,我封装了一个formatDateTime和一个formatCurrency函数,内部统一走Intl API,业务代码只管传入Date对象和金额,不要自己做任何拼接。这类基础函数,越早统一越好,不然后面改一次,所有页面都得跟着动。
2.4 从右到左语言适配:你的页面可能整个镜像
如果你的产品将来要支持阿拉伯语或希伯来语,那国际化不只是文案翻译,整个布局都要反一反。英文是从左往右读,阿拉伯语从右往左,导航栏、文字对齐、图标方向全得跟着变。
好在这个问题CSS本身有方案。dir属性切换后,flex布局和grid布局大部分情况下会自动镜像,但这不是白来的,有几个地方必须提前设计:
- 文本对齐不能用硬编码的
text-align: left,尽量用逻辑属性text-align: start和end - margin和padding也尽量用
margin-inline-start这类逻辑属性,而不是margin-left、margin-right - 箭头图标、翻页按钮的左右方向,需要做镜像处理
CodeSpirit的做法是在根节点动态设置dir属性,配合一套RTL样式覆盖变量。做这块时最好找母语者测试,光靠开发自己看是看不出问题的——我自己曾经把阿拉伯语页面做出来,箭头全反了还不自知,直到被用户截图打脸。
2.5 语言包里的“硬编码”漏网之鱼
文案抽离最怕的不是抽得慢,而是抽不干净。常见漏网之鱼包括:
- 图片里的文字:比如按钮背景图里写着“立即购买”,这种只有设计师重新出图才能解决
- 后端返回的错误信息:后端接口直接抛中文,前端翻译不了
- 页面标题
document.title的拼接字符串 - 第三方组件自带的文案,比如日期选择器的“今天”“确定”
这里没有银弹,全凭细心和规范。我习惯在每个迭代的代码审查里专门加一条“文案检查”,看到字符串字面量就问一句:这个东西会不会显示给用户?会的话,为什么不走语言包?这个习惯坚持下来,后期翻译遗漏率会降很多。
3. 从零搭建一套可用的国际化流程:安装配置到运行时切换
3.1 基础引入与初始配置:首次接壤的准备工作
以CodeSpirit前端项目为例,先安装国际化插件,这是vue和react生态里最常用的方案。安装好后,建立语言包目录并导入基础文案。初始化的核心配置大概长这样:
import { createI18n } from 'vue-i18n'; const i18n = createI18n({ legacy: false, locale: localStorage.getItem('locale') || navigator.language || 'zh-CN', fallbackLocale: 'zh-CN', messages: { 'zh-CN': { common: require('../locales/zh-CN/common.json'), auth: require('../locales/zh-CN/auth.json') }, 'en-US': { common: require('../locales/en-US/common.json'), auth: require('../locales/en-US/auth.json') } } });这里有两个关键点:
第一,fallbackLocale一定要设置。它的作用是,当某个key在英文语言包里缺失时,自动降级用中文填充,而不是直接把key名扔到页面上。这能避免很多“半成品”的尴尬画面。
第二,首屏的locale不要写死,优先从用户过往选择读取。localStorage里存了上次的偏好就用偏好,没有就用浏览器语言。这算是最朴素的用户体验优化。
还需要在index.html里提前设置好lang属性和dir属性。ESLint等静态检查工具也建议配一个html标签的lang校验,可以提前发现很多疏忽。
3.2 运行时切换语言:不只是换语言包,状态和路由也得联动
语言切换看起来就是点个按钮把locale变量改掉,但真实落地要注意三件事。
第一件事:切换后要手动触发重新渲染。有些框架的响应式系统不会自动追踪locale的变化,你改了值页面却没反应,让人怀疑自己写错了。解决方法是把当前语言作为响应式依赖注入,或者显式调用nextTick后重新渲染。
第二件事:持久化当前语言。切换语言后立即存入localStorage,刷新页面时还能保持用户的偏好。这个细节虽然简单,但没做的话,用户每次打开都要重新选语言,体验分直接扣一截。
第三件事:异步加载语言包。切换语言时如果目标语言包还没加载,需要动态import,然后等加载完成再更新locale:
async function changeLocale(locale) { if (!loadedLocales.has(locale)) { const messages = await import(`../locales/${locale}/index.js`); i18n.global.setLocaleMessage(locale, messages.default); loadedLocales.add(locale); } i18n.global.locale.value = locale; localStorage.setItem('locale', locale); }这种懒加载模式的好处是,首包不携带所有语言,英文用户访问中文站点时,不会为了看英文而先下载一整包中文。项目语言多了以后,这个性能收益非常明显。
还有一个小细节,切换语言后一些第三方组件不会自动跟着变。比如日期选择器,它内部默认是英文,切换成中文后还显示“January”。这种组件一般自带locale属性,需要在切换时主动传入对应配置。这也是国际化里比较头疼的一类问题,没有统一的解决方案,只能逐个排查组件文档。
3.3 带参数的消息、富文本消息与动态key的安全接入
语言包里除了纯静态文案,还有大量带动态参数的消息。比如订单状态的提示,需要把订单号放到句子里。用前面的命名参数方案,在模板里这样调用:
<p>{{ t('order.created', { orderNo: order.orderNo }) }}</p>这种用法是基础,但有两个进阶场景需要注意。
一是富文本。有些文案里需要嵌入链接、加粗文字等HTML标签,语言包不能只存纯文本。CodeSpirit的实践是把富文本拆成段落级别的key,在模板里组合渲染,而不是试图在JSON里写HTML片段。看到有人把整段HTML塞进语言包的,劝一句:翻译工具的转义问题会让你哭。
二是动态key。有时候key的一部分是变量,比如order.status.${status}这种写法。这种风格要尽量避免,因为维护困难,翻译的人不知道这个key可能有哪些值,测试也覆盖不全。正确的做法是把所有状态列出来,在语言包中平铺写出,宁可显式写五个key,也别“聪明”地拼一个动态key。
3.4 静态提取与自动检查:别让人工翻译成为瓶颈
文案写得规范,英文翻译的质量就不会差,但项目大了以后,人工翻译跟不上开发速度是必然。这时候工具链就派上用场了。
我常用的是一个“提取-对齐-校验”三件套流程。构建时自动扫描代码中所有调用t()和$t()的位置,收集第一个参数中的key,和语言包中的key做对比。缺失的key输出警告,多余的key输出提醒。这个流程虽简单,但能保证“提交代码时语言包基本是齐的”。
翻译本身可以用现成的翻译API做初稿,再人工校对。不过要小心,机器翻译在专业术语上经常翻得不准,比如“提现”“风控”“权益”这类词,机器很可能翻译成字面意思,离业务本意差远了。初审之外的终审,必须由熟悉业务的人来做。
4. 上线后最容易翻车的几个问题:排查思路与避坑实录
4.1 问题一:某些页面切换语言后没反应
排查顺序有讲究。先看控制台有没有报错,多半是语言包加载失败,key找不到。再看网络面板里语言包资源有没有真的发请求,没发就是懒加载逻辑里loadedLocales的状态更新出了问题。
如果页面局部没反应,重点检查组件是不是没有绑定当前语言这个响应式依赖。比如你在setup里解构了t,但使用的是普通变量而不是computed,切换后组件不会重新渲染。这个属于响应式依赖丢失问题,记住一个原则:所有跟语言相关的派生状态,都要用computed包一层。
4.2 问题二:日期组件、富文本组件语言跟主体不一致
第三方组件没跟随语言切换,通常是因为组件内部有自己的国际化管理,挂在全局的locale上。排查时先确认组件文档里属性名称,然后创建一个统一的管理器,在切换语言时同步更新所有已注册组件的locale。
我一个项目里同时接了日期组件、富文本编辑器、代码高亮,它们各有各的语言包,切换逻辑各不相同。后来写了一个syncComponentLocale函数,在语言切换流程里逐个调用每个组件实例的更新方法。这个函数名字听起来土,但确实管用。
4.3 问题三:动态内容里的时间还是旧格式,或货币符号错乱
这个跟响应式有关系,但本质是“格式化函数的执行时机”问题。如果你在组件初始化时把时间字符串格式化好了,存成普通变量,语言切换后这个字符串不会重新计算。务必把格式化放到渲染期,或者用computed包裹。
货币符号错乱的情景,常见于金额数据在接口层被处理成字符串返回,前端拿不到数字,也就没法做Intl.NumberFormat。后来我们约定后端一律返回数字,前端负责格式化,彻底根治了这个隐患。
4.4 避坑经验:搭建一个“翻译检查面板”比一百次口头强调都管用
最后一个建议,听起来有点“重”,但收益不小。在内部开发环境里,我习惯做一个隐藏的翻译检查面板:把所有语言包缺失的key、未翻译的条目、格式可疑的值,汇总成列表按页面展示。测试同学在验证功能时顺手打开面板瞄一眼,几秒钟就能发现有没有漏翻译的文案,比事后翻截图高效太多。
做法也不复杂:拦截所有翻译调用的返回结果,如果发现返回的字符串里还含有{或},就判定为key缺失,把信息推送到面板上。
理论上这不难,难的是坚持做。但说实话,就靠这个面板,我们有一次赶上线日程时,在测试环境里提前拦截了二十多个漏翻的key,避免了一场线上事故。
5. 交互体验与性能优化的几个加分项
5.1 骨架屏和加载状态里,语言也要跟着变
很多团队只注重完整页面的语言切换,却忽略了加载态和异常态。用户切换语言后,骨架屏上的占位文字还停留在旧语言,这种不一致最容易被人截图挑刺。骨架屏文案也要从语言包里取,同时要保证切换语言的瞬间,骨架屏立即刷新。这需要在模板里把骨架屏用到的文案同样定义为响应式依赖。
5.2 按需加载与缓存策略:语言包不该成为首屏负担
国际化不是“把所有语言都挂在首屏资源里”,而是“用户需要什么语言,就提供什么语言”。首屏只加载当前语言的必要模块,其他语言模块等用户切换时再异步加载。这本身就是最基础也最有效的优化。
加上缓存策略后,效果更好。语言包变更频率低,非常适合给一份强缓存,同步更新时用构建hash或版本号做资源更新。曾经有段时间我们某个语言包没加版本号,导致用户改了偏好语言后,依然拉取旧缓存,页面上一半新文案一半旧文案,排查了半天才发现是缓存问题。现在语言包文件名里带上内容hash,这个问题再没出现过。
5.3 从“地道翻译”到“本地化”:语言背后的文化适配
“翻译”和“本地化”是两件事。真正好的国际化,不只是把中文换成英文,还要考虑当地的文化习惯。比如:
- 日期是“2024年1月5日”这样的表达习惯,还是“1/5/2024”?
- 数字是十进制的千分位格式,还是某些国家习惯用空格分隔?
- 首字母大小写规则、标点符号、地址书写顺序……
这些细节靠翻译工具搞不定,需要各个国家地区的真实使用场景来反推。如果你的产品将来要出海,建议早一点找到目标市场的用户做语言包评审,而不是等上线后让用户帮忙“找茬”。
6. 写在最后:国际化不是一次改造,而是一套持续运转的机制
做国际化改造,代码层面的工作量大概只占四成,剩下六成都花在流程和方法论上。语言包的维护不是上线那天结束,而是从那天才开始。后续每个迭代都可能新增文案,每个文案都可能漏翻译,每个翻译都可能不地道。所以关键不是上线那天的状态,而是你有没有建立一套让语言包“持续被维护”的机制——比如代码审查里的文案检查、构建时的key对齐校验、测试环境的翻译面板。
按我个人的经验,先用一个小模块试点,把语言包结构、命名规范、运行时切换流程跑通,再推广到全项目。别一上来就全面铺开,不然光是排查历史遗留文案就够你消耗掉所有精力。希望这套CodeSpirit的国际化实践,能让你在改造路上少踩几个坑。