2026/10/18 5:30:45

前端多语言实战:从国际化i18n到本地化l10n的完整落地指南

前端多语言实战:从国际化i18n到本地化l10n的完整落地指南 接了个需要上多语言的官网项目甲方要求中英文切换上线时间卡得死。说实话刚接需求时心里也发虚但真正动手后发现前端国际化i18n和本地化l10n并没有想象中那么吓人。这篇内容就是按我实际做事的顺序整理的从概念拆解到代码落地再到上线前检查三天时间足够一个前端新手走完全程。你如果正被加多语言这个需求折腾照着这个路径走基本不会翻车。1. 动手前先盘清楚国际化与本地化到底要做什么1.1 一句话说清i18n与l10n的区别很多人把国际化和本地化混在一起其实它们是两件事。国际化Internationalization简称i18n是让系统具备支持多语言的能力比如把写死的文案抽离成翻译条目、做好区域格式化本地化Localization简称l10n是给特定语言和地区填充具体内容比如把界面上确认翻译成Confirm、把日期显示成当地习惯的格式。通俗点说国际化是修管道本地化是放水。管道没修好后面放多少水都会漏。很多项目翻车就是因为在国际化阶段偷了懒以为只要把中文文案替换成英文就行结果日期格式、排序规则、文本长度、RTL从右到左排版这些问题在后期集中爆发改起来比重新开发还痛苦。1.2 把需要翻译的内容盘点成清单动手写代码前我先花了一个小时把整个项目里所有面向用户的内容全部列了一遍。这一步不能省漏一项上线后就是事故。一般来说需要纳入多语言管理的内容包括页面文案按钮、标题、提示语、空状态、错误提示。日期和时间创建时间、发布时间、订单时间、倒计时。数字和货币价格、数量、订单号、百分比。表单校验信息必填提示、格式错误提示。路由和页面标题URL路径、浏览器标签标题、SEO描述。图片和资源文件含有文字的图片、音视频字幕。我的习惯是建立一个Excel表格列名分别是模块、Key、中文、英文、备注。把页面上能看到的每一个文字都填进去然后拿给业务方去核对。你会发现业务方对已下单和订单已提交这种近义词的执着比你想象的深趁早让他们确认比后期返工强。1.3 技术方案怎么选别一上来就装库技术选型的核心不是哪个库最流行而是你的项目结构是什么。以Vue为例vue-i18n是事实标准React对应的是react-i18nextNuxt和Next这类全栈框架还有自己的集成方案。如果你只是做一个静态展示页甚至可以不引入库自己写一个简单的翻译对象加切换函数就够了。我在这个项目里用的技术栈是Vue 3 Vite选vue-i18n9理由有三点它和Vue 3的组合式API配合很自然支持语言包按需加载社区方案成熟遇到问题基本都能搜到答案。如果你用的是Reactreact-i18next配置思路几乎一样后面章节我会补充对应写法。还有一个容易被忽略的选择用不用TypeScript。强烈建议用。翻译Key有了类型提示之后你删Key、改Key时编译器会直接告诉你哪里漏了这个体验谁用谁知道。2. 第1天搭好国际化脚手架让页面先能切换语言2.1 初始化项目与安装依赖先确认环境我用的是Vite创建的项目Node版本16以上。安装依赖的命令很简单npm install vue-i18n9装完之后在src目录下建一个专门放国际化相关代码的文件夹我的习惯是src/i18n里面再分出一个locales目录放语言包。这样等语言多了结构也不会乱src/ ├── i18n/ │ ├── index.ts # i18n实例创建和配置 │ └── locales/ │ ├── zh-CN.ts # 中文字文案 │ ├── en-US.ts # 英文文案 │ └── types.ts # 翻译Key类型2.2 语言包怎么组织命名规则怎么定语言包的本质是一个对象结构就是你脑海里的菜单嵌套。我见过很多人把Key命名为home_title、about_desc这种扁平结构短期没问题一旦页面多起来Key就会膨胀到难以维护。我推荐按模块划分用嵌套对象组织// locales/zh-CN.ts export default { common: { confirm: 确认, cancel: 取消, save: 保存 }, checkout: { title: 结算, payNow: 立即支付, totalAmount: 应付金额 } }对应的英文包// locales/en-US.ts export default { common: { confirm: Confirm, cancel: Cancel, save: Save }, checkout: { title: Checkout, payNow: Pay Now, totalAmount: Total Amount } }这样写的好处是当你需要修改结算页面的文案时只需要看checkout这个模块下的内容不需要顺着几十个扁平Key去猜。还有个细节Key用英文命名值才是翻译文本。很多人反着来把中文当Key英文当值看起来省事但后期你加日文、法文时等于给每种语言都准备了一套不同的Key维护成本直线上升。2.3 创建i18n实例并接入应用语言包准备好后创建i18n实例// src/i18n/index.ts import { createI18n } from vue-i18n import zhCN from ./locales/zh-CN import enUS from ./locales/en-US const i18n createI18n({ legacy: false, // 使用组合式API模式 locale: zh-CN, // 默认语言 fallbackLocale: en-US, // 找不到翻译时回退到英文 messages: { zh-CN: zhCN, en-US: enUS } }) export default i18n然后把i18n挂载到应用上在main.ts里import { createApp } from vue import App from ./App.vue import i18n from ./i18n const app createApp(App) app.use(i18n) app.mount(#app)这里有几个配置值得解释一下。legacy: false很重要它让你可以用useI18n()这种组合式API而不是老式的this.$t。fallbackLocale是保命选项如果某个Key在中文包里忘了写至少还能显示英文不至于让用户看到一堆花括号。当然这只是兜底真正上线前还是要把漏翻译的Key清干净。2.4 组件里开始使用翻译函数在Vue 3组件中使用翻译函数的方式很简单template div h1{{ t(checkout.title) }}/h1 button{{ t(common.confirm) }}/button /div /template script setup import { useI18n } from vue-i18n const { t } useI18n() /script在script里拼接动态文案时不要写死字符串也用t函数。比如const message t(checkout.totalAmount) price如果你发现t函数调用特别多、模板里全是Key这其实是正常现象。我习惯在组件内把重复用到的Key提取成变量或者在模板里直接用没必要过度封装。但有一点要克制不要在业务逻辑里写locale zh-CN这种判断来决定显示内容这等于绕开了i18n体系后面改起来非常痛苦。2.5 完成第一步的语言切换与持久化语言切换的本质是修改i18n实例的locale变量。在Vue 3组合式API模式下可以这样实现script setup import { ref } from vue import { useI18n } from vue-i18n const { locale } useI18n() const currentLang ref(locale.value) function switchLang(lang) { locale.value lang currentLang.value lang localStorage.setItem(app_lang, lang) } /script在应用启动时读取用户之前的选择const savedLang localStorage.getItem(app_lang) if (savedLang) { i18n.global.locale.value savedLang }这里我踩过一个坑如果你在Vue 3的legacy:false模式下直接修改i18n.global.locale可能会遇到类型不匹配的报错因为组合式API模式下locale是一个Ref对象需要用.value赋值。如果你的项目里既有选项式API组件又有组合式API组件legacy: true的配置会让切换逻辑有些差异建议新项目统一用组合式API模式。3. 第2天处理动态文案、时间、数字这些硬骨头3.1 动态插值、复数与语境匹配到了第2天基础切换已经没有问题了接下来要处理真正让新手头疼的动态内容。首先是动态插值比如你有3条未读消息你不能写死成你有{{ count }}条未读消息英文的语序完全不同它可能是You have {{ count }} unread messages。正确的做法是把整个句子交给翻译包管理用参数占位符// 语言包里 messages: { zh-CN: { unread: 你有 {count} 条未读消息 }, en-US: { unread: You have {count} unread messages } }组件里t(unread, { count: unreadCount })这样中英文的语序差异就完全交给翻译包去处理了。再进一步英文的单复数逻辑和中文不一样中文不管数量多少都叫条消息英文要区分message和messages。vue-i18n支持复数规则// en-US unread: You have {count} unread messages | You have one unread message | You have {count} unread messages管道符分隔的几种形式分别对应单数、复数等情况。中文包则只需要一条// zh-CN unread: 你有 {count} 条未读消息如果你不想在处理复数规则上花太多时间还有一种更省心的做法在英文包里直接写成Messages ({count})这种不含单复数变化的样式很多后台系统就是这么处理的既简洁又不会出语法错误。3.2 日期、金额、数字的本地化格式这是整个国际化项目里最容易被忽视、也最容易出Bug的部分。不同语言环境下日期格式、货币符号、小数点、千分位分隔符都不一样。很多新手只想着翻译文字结果日期显示成12/05/2024美国用户看不明白欧洲用户也看不明白。我的建议是使用浏览器自带的Intl对象不需要额外引库// 日期 new Intl.DateTimeFormat(en-US, { dateStyle: medium }).format(new Date(2024-05-12)) // May 12, 2024 new Intl.DateTimeFormat(zh-CN, { dateStyle: medium }).format(new Date(2024-05-12)) // 2024/5/12 // 货币 new Intl.NumberFormat(en-US, { style: currency, currency: USD }).format(1999) // $1,999.00 new Intl.NumberFormat(zh-CN, { style: currency, currency: CNY }).format(1999) // ¥1,999.00 // 数字 new Intl.NumberFormat(de-DE).format(1234567.89) // 1.234.567,89在使用Intl时一定要注意地区代码的取值来源。我建议语言切换时把locale同时存起来日期格式化时直接取当前语言对应的地区码不要自己手动拼接字符串。另外还有个比较隐蔽的问题后端返回的时间如果没有带时区信息前端直接new Date()解析会产生偏差。我遇到过后端返回2024-05-12 10:00:00不同浏览器解析逻辑不一样有的按本地时区有的按UTC。最稳妥的方案是后端统一返回带时区的ISO字符串比如2024-05-12T10:00:00Z前端解析就不会有歧义。3.3 更优雅的翻译文案组织ICU MessageFormat如果你的项目文案经常包含复杂的复数、选择语境比如你有一个苹果/你有多个苹果光靠普通的字符串占位符可能不够用。vue-i18n底层支持ICU MessageFormat语法在文案里可以写条件逻辑// 语言包 cartItem: {count, plural, 0 {Cart is empty} one {# item} other {# items}} // 调用 t(cartItem, { count: 3 })还有选择格式// 语言包 gender: {gender, select, male {He is} female {She is} other {They are}}这个语法学习成本不高但效果很实用。它本质上把文案的逻辑判断从组件代码里挪到了翻译包里业务方改文案时不用动代码。如果你的翻译包越来越庞大建议上可视化翻译管理平台或者至少把ICU语法控制在少量高复用条目上别为了炫技把简单文案也搞复杂。3.4 React生态下的对应操作如果你用的是React整体思路和Vue几乎一致只是库的API稍微不同。以react-i18next为例import { useTranslation } from react-i18next function CheckoutPage() { const { t, i18n } useTranslation() return ( div h1{t(checkout.title)}/h1 button onClick{() i18n.changeLanguage(en-US)} {t(common.confirm)} /button /div ) }i18next的初始化配置放到独立的配置文件中import i18n from i18next import { initReactI18next } from react-i18next import enUS from ./locales/en-US.json import zhCN from ./locales/zh-CN.json i18n.use(initReactI18next).init({ resources: { en-US: { translation: enUS }, zh-CN: { translation: zhCN } }, lng: zh-CN, fallbackLng: en-US })react-i18next的t函数和Vue的t函数在模型上非常相似如果你先深入理解了一边的概念另一边上手会非常快。我在两个框架里都写过国际化核心思想永远是文案外置 状态统一 格式化交给标准库。4. 第3天路由结构、SEO与上线前检查4.1 多语言URL怎么设计才不踩坑多语言站点的URL设计不是小事它影响用户体验、SEO、以及后端路由的复杂度。我这次遇到的应用是一个官网加后台管理我采用了两种不同策略官网用路径前缀方式URL结构是/zh-CN/about /en-US/about后台管理不需要SEO运行时切换语言即可URL保持不动。选择路径前缀而不是子域名比如en.site.com主要考虑是部署简单不需要为每种语言配置独立子域名也不需要处理跨域Cookie问题。但如果你面向的市场特别多比如要做欧洲多国业务用子域名也不是不行只是运维成本高一些。实现路径前缀的思路是在路由配置里加一层带:locale参数的父路由或者在路由守卫里统一处理。用Vue Router时我是在路由元信息里标记是否需要本地化然后用一个前置守卫根据当前语言重定向到正确的路径router.beforeEach((to) { const lang localStorage.getItem(app_lang) || zh-CN if (!to.path.startsWith(/${lang}) to.meta.localized) { return /${lang}${to.path} } return true })这里有个细节切换语言时要同步跳转到同页面、不同语言前缀的URL不能随便跳回首页。我会把当前路由的path和query保存下来在切换语言时重新拼接新URL。4.2 hreflang与页面元信息处理做多语言站点如果不上搜索引擎那无所谓但凡要面向海外用户就必须处理SEO的hreflang标签。它的作用是告诉搜索引擎同一页面有不同语言的版本分别对应哪些URL避免Google判定你是在复制内容。在head里加HTML标签link relalternate hreflangzh-CN hrefhttps://example.com/zh-CN/about / link relalternate hreflangen-US hrefhttps://example.com/en-US/about / link relalternate hreflangx-default hrefhttps://example.com/about /在Vue项目中我习惯在路由切换后动态更新这些标签用一个useHeadMeta这样的组合式函数统一管理。如果你用的是VitePress、Nuxt这类带SEO能力的框架会有内置的head管理API直接在页面级配置即可。还有html标签上的lang属性别小看它。HTML的lang属性不仅影响搜索引擎理解页面语言还会影响浏览器翻译插件的自动提示。在应用初始化时动态把document.documentElement.lang设置成当前语言这是很容易被忽略的小细节。4.3 语言包按需加载与构建优化如果语言包文件很大或者站点支持的语言很多一次全量加载会拖慢首屏。我建议用动态import的方式按需加载语言包const i18n createI18n({ legacy: false, locale: zh-CN, fallbackLocale: en-US, messages: {} // 初始为空 }) // 切换语言时动态加载 async function loadLanguage(lang) { const messages await import(./locales/${lang}.ts) i18n.global.setLocaleMessage(lang, messages.default) i18n.global.locale.value lang }Vite和webpack对这种动态import的支持都很好会单独打包成chunk用户访问时只加载需要的语言包。我实测过语言包从全量打包变为按需加载后首屏JS体积能降不少。还有一种优化是把高频使用的公共文案和低频的模块文案拆分成两个文件。比如common模块打包进主包其他模块按路由懒加载。这个策略实施起来不难稍微增加一点构建配置就能做到。4.4 上线前逐项检查清单第3天的下午不要写新功能专门做检查。我整理了一份多语言上线前检查清单每次发版前照着过一遍检查项做法验收标准漏翻译扫描用脚本扫描语言包找出缺失Key没有缺失Key硬编码文案搜索页面中的中文字符串除Mock数据和注释外无中文布局检查切换到英文查看所有页面文本不溢出、不遮挡按钮日期切换故意切换语言后跳转日期页面日期格式跟随语言变化刷新持久化切换语言后刷新页面语言保持用户选择空状态文案清空列表数据查看空状态空状态文案正确翻译浏览器返回多语言URL之间来回跳转不丢失语言信息URL参数切换语言时带query参数query被保留不带乱码这份清单我打印过贴在显示器边上几乎每个项目都能在漏翻译扫描和布局检查两项上抓到问题。不要轻信自己写的代码没有硬编码我每次都能在我不注意的组件里发现中文文案比如表格里某一列的默认值、弹窗的关闭按钮、甚至是loading提示。5. 排坑实录这些坑我帮你踩过了5.1 中文文案天生比英文短布局千万别写死这是新手最容易踩、也最难察觉的坑。中文文案通常比英文要短尤其是按钮上的文字。我在一个项目的表单页用了固定宽度的按钮中文显示保存没问题英文Save Changes直接换行挤成一团丑得不忍直视。我的经验是给按钮和标签文字预留至少30%的冗余空间能用自动宽度的绝对不要写死像素值在布局中尽量用flex布局让文本自适应实在不行再考虑使用CSS的min-width兜底。还有一个实用技巧英文文案控制在80个字符以内超长文案考虑拆分或缩写这不仅是为了美观渲染性能也有影响。5.2 翻译Key膨胀维护才是大问题项目上线三个月后最大的问题不是功能Bug而是翻译Key没有人管了。有人删了某个功能但忘了删Key有人改了文案但懒得更新Key最后语言包里堆了几百个僵尸Key。我的建议是第一Key命名必须可读能直接从Key猜出用途第二每次版本更新时用脚本对比代码中实际用到的Key和语言包中存在的Key找出差异第三翻译Key的评审应该和代码评审同步进行让团队成员意识到Key也是有生命周期的资源。如果你不想自己写脚本vue-i18n官方有配套的ESLint插件可以做这种检查配置起来也不麻烦。5.3 时区、夏令时与早上好的隐藏问题时间戳处理在多语言项目里是重灾区。我遇到过两个典型问题第一个是后端返回的时间戳是服务器时区比如UTC前端直接用new Date转换后显示变成了凌晨3点用户一脸懵。后来要求后端统一返回带偏移量的ISO字符串前端再根据用户时区格式化。第二个是早上好今天是周五这种动态问候语它其实同时依赖时间和语言。我的处理方式是做一个统一的getGreeting(lang, date)函数内部用Intl的hour参数判断时间段然后返回对应语言的问候Key。还有夏令时问题主要影响定时任务或日期选择器。前端做跨时区的日期计算时最好不要手动加减小时数而是用Date.UTC或者专门的日期操作库来算。5.4 标点符号和换行的细节差异中文标点是全角英文是半角翻译包很容易漏掉这个细节。我见到过的最典型Bug是中文文案结尾是。翻译直接替换成单词但忘了把句号改成.结果页面上出现中英混排的标点。换行规则差别也很大。英文不能像中文一样密集换行英文排版通常需要给line-height留足够空间否则字母的上下沿会挤在一起。我在英文版页面上统一设置了比中文版稍大的行高读起来舒服很多。另外有些语言会特别长。同样一个确认按钮德语Bestätigen可能比中文长一倍布局如果写死就直接翻车了。5.5 常见问题速查表问题现象原因解决办法页面显示checkout.title本身Key不存在或拼写错误检查语言包Key开启fallbackLocale切换语言后日期没变化日期格式化写死了zh-CN用Intl.DateTimeFormat(locale)动态格式化页面标题没有跟随语言路由meta中的title是静态的使用useI18n返回的t函数动态设置title用户刷新后语言恢复默认没有读取本地存储初始化时读取localStorage或Cookie英文按钮文字溢出布局宽度不足调整布局给文本预留30%空间语言包更新后用户没看到新文案构建缓存语言包文件增加hash或设置较短的Cache-Control浏览器语言检测不生效没有读取navigator.language初始化时用navigator.language.startsWith判断后端返回的中文乱码接口编码不一致确保后端返回UTF-8前端不做额外转码切换语言后路由还停留在旧地址没有同步更新URL路径切换语言时用router.push拼接新路径非英文字体显示异常缺少对应字体资源为RTL或特殊字符集加载字体比如阿拉伯语以上这些都是我在实际项目中真实遇到的问题甚至有些问题犯了不止一次。比如标点符号的坑我直到第三轮迭代才彻底养成检查习惯。你在做多语言方案时如果时间紧可以直接把我这份速查表当上线前检查清单用会帮你少走很多弯路。我个人现在做多语言项目的习惯是无论项目大小都从第1天就引入国际化框架哪怕一开始只有中文一个语言也不迟。因为后期再改造的成本远比一开始就按国际化结构来写要大得多。文案抽离、组件改造、路由兼容这些事拖得越久积攒的债务就越大。三天的流程其实前两天的代码量并不大大部分时间都花在盘点和检查上。只要把该想的边界情况都提前想清楚多语言上线并不可怕。