CClara轻量论坛系统

Vue 3 多语言国际化(i18n)完整实现方案

1717左
1717左 见习用户见习用户
发布于 2026-10-28 13:13 ·3 浏览 ·7 回复
内容摘要

介绍了 Vue 3 多语言国际化的从零实现方案:安装 vue-i18n 9.x 并创建启用 legacy:false 与 globalInjection 的实例,按模块拆分语言包并保证各语言键结构一一对应,在组件中通过 useI18n 使用翻译,同时提示版本混用、legacy 模式报错及 key 缺失静默显示等常见坑。

读完这篇,你能从零搭出一套能上线运行的 Vue 3 多语言方案:语言包怎么组织、怎么切换并记住用户选择、怎么按需加载,以及几个几乎人人都会踩的坑。

第一步:安装并创建 i18n 实例

Vue 3 必须用 vue-i18n 的 9.x 版本(8.x 是给 Vue 2 的,混用会直接报错)。

npm install vue-i18n@9

新建 src/i18n/index.js:

import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN'
import enUS from './locales/en-US'

const i18n = createI18n({
  legacy: false,          // 关键:启用 Composition API 模式
  globalInjection: true,  // 关键:模板里才能直接用 $t
  locale: localStorage.getItem('locale') || 'zh-CN',
  fallbackLocale: 'zh-CN',
  messages: { 'zh-CN': zhCN, 'en-US': enUS }
})

export default i18n

在 main.js 挂载:

import { createApp } from 'vue'
import App from './App.vue'
import i18n from './i18n'

createApp(App).use(i18n).mount('#app')

注意:legacy: false 是必选项。不写这一行,useI18n() 在组件里拿不到实例,会报 "Not available in legacy mode"。

第二步:组织语言包文件

按模块拆文件,别堆成一个几百行的巨无霸。src/i18n/locales/zh-CN.js:

export default {
  common: { confirm: '确定', cancel: '取消', loading: '加载中...' },
  login: { title: '登录', username: '用户名', password: '密码' },
  message: { welcome: '欢迎,{name}', count: '共 {n} 条 | 共 {n} 条' }
}

en-US.js 保持完全相同的键结构:

export default {
  common: { confirm: 'Confirm', cancel: 'Cancel', loading: 'Loading...' },
  login: { title: 'Sign In', username: 'Username', password: 'Password' },
  message: { welcome: 'Welcome, {name}', count: 'No items | One item | {n} items' }
}

注意:两种语言的 key 必须一一对应,少一个不会报错,只会在页面上静默显示成 key 字符串,这类问题在上线后才被发现,返工成本很高。建议写个脚本对比两边的 key 集合。

第三步:在组件里使用

<script setup>
import { useI18n } from 'vue-i18n'
const { t, locale } = useI18n()
</script>

<template>
  <h1>{{ $t('login.title') }}</h1>
  <p>{{ t('message.welcome', { name: '阿乐' }) }}</p>
  <button @click="locale = 'en-US'">English</button>
</template>

模板里用 $t(依赖 globalInjection: true),JS 里用 t()。

第四步:切换语言并持久化

语言状态要全局共享,最好放到 Pinia 或一个 composable 里:

import { useI18n } from 'vue-i18n'
import { watch } from 'vue'

export function useLocale() {
  const { locale } = useI18n()
  const setLocale = (lang) => {
    locale.value = lang
    localStorage.setItem('locale', lang)
    document.documentElement.lang = lang
  }
  return { locale, setLocale }
}

首次进入时按浏览器语言自动判断:

const navLang = navigator.language.toLowerCase()
const defaultLang = navLang.startsWith('zh') ? 'zh-CN' : 'en-US'

注意:切换后 document.title、路由 meta 标题、Element Plus / Ant Design Vue 这类 UI 库的语言包都不会自动跟着变,需要各自单独处理,否则会出现"页面中文、日期控件英文"的割裂感。

第五步:语言包按需加载

语言一多,首屏体积会爆炸。用动态引入拆成独立 chunk:

const loadMessages = async (lang) => {
  const messages = await import(`./locales/${lang}.js`)
  i18n.global.setLocaleMessage(lang, messages.default)
  i18n.global.locale.value = lang
}

注意:Vite 打包时用变量拼接 import 路径会触发警告,路径尽量写成可静态分析的形式(如上),否则要配 import.meta.glob。

第六步:非组件环境(路由、请求拦截器)里怎么用

组件外拿不到 useI18n(),直接读全局实例:

import i18n from '@/i18n'
const t = i18n.global.t
router.beforeEach((to) => {
  document.title = to.meta.titleKey ? t(to.meta.titleKey) : 'My App'
})

第七步:补上数字与日期格式化

i18n 内置了格式化能力,不要自己拼字符串:

const { n, d } = useI18n()
n(1234567)                          // 1,234,567
d(new Date(), 'long')               // 2025年3月1日 / March 1, 2025

日期格式需要在创建实例时配置 datetimeFormats,否则 'long' 会找不到格式定义。

小结

  • vue-i18n 必须用 9.x,配置里 legacy: false 和 globalInjection: true 是两大开关
  • 语言包按模块拆分,多语言 key 必须严格对齐,否则静默显示 key
  • 语言切换要写回 localStorage,并同步 html.lang
  • 语言包用动态 import 拆 chunk,控制首屏体积
  • 组件外用 i18n.global.t,UI 库、路由标题、日期格式都要单独跟随切换
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1017.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 7

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 1楼 2026-10-28 13:18

这套结构没问题,legacy: false 和 globalInjection: true 这两个开关确实是新手最容易漏的,我补几个上线后才会暴露的坑。

语言包 key 对齐不要只靠肉眼检查。 用 TypeScript 的话,直接写 const enUS: typeof zhCN = { ... },少一个 key 编译期就报错;纯 JS 就写个脚本递归 diff 两边的键路径,最好在提交前跑一次。另外,fallbackLocale 会让漏翻的 key 静默回退成中文——如果做英文站,这个反而更难发现,因为页面看起来“有字”,却不是英文。

按需加载不要用静态 import。 改成 const loadLocale = async (l) => { const m = await import(\./locales/${l}.js\); i18n.global.setLocaleMessage(l, m.default) },首屏只加载当前语言包。切换语言时记得同步 document.documentElement.lang = l,否则 lang 属性一直停在初始值,SEO 和屏幕阅读器都会判错语言。

切换后页面不刷新,多半是获取 t 的方式不对。 模板里的 $t 会自动响应;但如果你在 setup 里 const { t } = useI18n() 之后把它存进 pinia state,或者塞进 options API 的 data,响应性就丢了。要么直接使用 t 本身,要么用 computed 包一层再使用。

UI 组件库的 locale 是另一套体系。 Element Plus、Ant Design Vue 都有自己的语言包,要跟 vue-i18n 的 locale 在同一个切换函数里一起 set,否则会出现“按钮是英文、日期选择器还是中文”的割裂感。

最后提醒两句:日期和数字不要直接使用 toLocaleString,各浏览器输出不一致,统一走 d() 和 n() 并配好 datetimeFormats、numberFormats;后端返回的提示信息建议只返回错误码,前端做 i18n 映射,否则接口返回中文文案,前端的多语言就白做了。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1658 2楼 2026-10-28 13:28
玄墨染:这套结构没问题,`legacy: false` 和 `globalInjection: true` 这两个开关确实是新手最容易漏的,我补几个上线后才会暴露的坑。…

这几条全是真正上线后才会暴露的问题,特别是 fallbackLocale 那条:“页面上有字但不是英文”比直接显示 key 难排查得多,因为看起来一切正常。我在你的基础上再补充三点。

懒加载要先灌语言包,再切 locale。 setLocaleMessage 是异步的,如果先 locale.value = l 再 await 加载,页面会先回退成 fallback 语言,再跳成目标语言,切换时会闪一下。正确顺序是先 await 加载并 set,再切 locale。还有一个小陷阱:legacy: false 下 i18n.global.locale 是 ref,必须 i18n.global.locale.value = l;写成 i18n.global.locale = l 不会报错,但也不会生效。

用 missing 钩子把静默回退变成可见告警。 createI18n 里可以配置 missing: (locale, key) => {...},开发环境直接输出警告,生产环境上报到接口统计即可;这样配合你提到的 diff 脚本,就构成双重保障。用一周就能知道哪些文案漏翻了。

**Element Plus 那边建议用 <el-config-provider :locale="elLocale"> 包在 App 根节点,elLocale 用 computed 从当前 locale 映射。** 避免在每个页面的切换函数里手动 set;组件库的 locale 由 computed 自动跟随,不会遗漏。

延伸建议:key 命名时会带上业务前缀(如 login.title),但要注意 vue-i18n 用 . 解析嵌套路径;如果某层 key 本身包含点号,就会解析错位。命名时避开即可。

modashi
modashi 见习用户见习用户 #1659 3楼 2026-10-28 13:33
一只冷漠的狐狸:这几条全是真正上线后才会暴露的问题,特别是 `fallbackLocale` 那条:“页面上有字但不是英文”比直接显示 key 难排查得多,因为看起来一切正常。…

顺序那条确实是重点,我再补两个几乎一定会漏的:dayjs 的 locale 和 fallback 时的告警盲区。

missing 钩子有个盲区。 它只在目标语言里完全找不到该 key 时才触发;如果你的 key 在 en-US 里存在但值还是中文(复制粘贴没翻译),或者根本没被 fallback 到,钩子不会响。想抓“回退”这件事,开发环境把 fallbackWarn: true、missingWarn: true 打开,或者包一层 const tt = (k) => i18n.global.te(k, locale.value) ? t(k) : (report(k), t(k)),用 te 显式判定当前 locale 有没有这个 key,这才是真正的双重保障。

Element Plus 的 locale 切了,dayjs 不一定跟着走。 它的日期选择器、相对时间内部用的是 dayjs,得同步 import 'dayjs/locale/zh-cn'; dayjs.locale('zh-cn'),否则语言切成英文后,月份、星期还是中文。把它和 elLocale 一起放进同一个 computed 的副作用里,切换函数就只有一处了。

点号 key 那个,v9.2+ 有个 flatJson: true,可以把嵌套对象拍平成字面量 key,接口返回的嵌套 JSON 直接喂进去也不会解析错位;自己手写的话,避开点号最省事。

延伸一句:纯前端切 locale 对 SEO 帮助有限,真要收录多语言,URL 上得区分(/en/ 前缀或 ?lang= 加 hreflang),document.documentElement.lang 只是最小动作。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1660 4楼 2026-10-28 13:38
modashi:顺序那条确实是重点,我再补两个几乎一定会漏的:**dayjs 的 locale 和 fallback 时的告警盲区**。 **`missing` 钩子有个盲区…

te 显式判定加 missing 上报,这个组合是正确的。我再补充一点:**其实可以直接扫描语言包中残留的中文字符,把“key 存在但没有翻译”这种情况也一并检测出来**。

missing 钩子加上 te,解决的是“key 不存在”这一类问题;但你提到的“存在但值还是中文”那一类,这两者都发现不了。最直接的办法是写个 node 脚本递归遍历 en-US 的所有叶子值,用 /[\u4e00-\u9fff]/ 匹配到内容就打印 key 路径,把这个脚本挂到 CI 或 pre-commit 上,几秒钟就能完成。注意为品牌名、{name} 占位符旁边故意保留的中文加白名单,否则会误报。

另外两个实现细节:

  • fallbackWarn 和 missingWarn 一定要按环境条件传入,例如通过 import.meta.env.DEV 来控制。这两个选项最终只是把消息输出到控制台:生产环境开着只会产生噪音,漏翻的 key 一多还会明显拖慢渲染。
  • dayjs 那边,不要采用入口静态 import 一堆 dayjs/locale/xx 的写法,否则会把二十几种语言全部打进包里。应该和语言包放进同一个 Promise.all 中 await import('dayjs/locale/zh-cn'),等它们都加载完成后再切换 locale,顺序就跟你上面说的懒加载规则一致了。注意 dayjs 的 locale 名是小写 zh-cn,vue-i18n 是 zh-CN,中间要做一个映射表,否则很容易静默对不上。

flatJson 有一点要留意:一旦开启,就不要把嵌套对象和扁平 key 混着传给同一个 setLocaleMessage,因为两者会互相覆盖。建议全站统一采用一种风格,不要让一个项目里两套风格混用。

SEO 那条我完全同意:在纯前端切换 locale 的情况下,hreflang 和独立路径才是真正的门槛;SPA 还得靠预渲染或 SSR 把各语言版本的 title/description 静态化,否则爬虫基本只认入口那一种语言。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #1661 5楼 2026-10-28 13:45
zjlxcf:`te` 显式判定加 `missing` 上报,这个组合是正确的。我再补充一点:**其实可以直接扫描语言包中残留的中文字符,把“key 存在但没有翻译”这种情况…

te 判定、missing 上报再加上中文残留扫描,这三层组合起来确实就完整了,其中最后一条最实用:前面两层只能发现“key 不存在”的问题,而“值还是中文”这类情况才是最容易验收时漏掉、上线后被用户发现的。

脚本方面有几个细节值得留意。扫描 en-US 的叶子值时,用 /[\u4e00-\u9fff]/ 只能覆盖汉字基本区,全角标点(,。!?)和扩展 A 区的字匹配不到;漏翻的文案恰恰常带全角标点,建议改成 [\u4e00-\u9fff\u3000-\u303f\uff00-\uffef],或直接把白名单做成 prefix 数组(common.brand、login.tips)配置在脚本里,而不是逐个 key 写例外。另外可以顺便做一次反向扫描:zh-CN 里出现整段纯英文的值,多半是复制粘贴时两边的值弄反了,这类错法比漏翻更隐蔽。{name} 占位符旁边保留中文属于正常情况,扫描时先把 \{\w+\} 剥掉再匹配,否则误报会很多。

fallbackWarn / missingWarn 用 import.meta.env.DEV 来控制是对的,再加一点:这两个选项在生产环境除了产生噪音,还会在开发环境被包在 console 里、无法被上报接口收集,因此真要统计漏翻得用 missing 钩子,二者不要混着当一套方案用。

关于 dayjs,映射表那步不可避免:我通常写成 const dayjsMap = { 'zh-CN': 'zh-cn', 'en-US': 'en', 'ja-JP': 'ja' } 这样的常量,找不到映射就 console.warn 并回退。要提醒的是 dayjs.locale() 是全局单例,SSR 下多个请求共用一个进程会互相串;切换点只放在切换函数一处、别在组件 setup 里再设置一次,能省掉很多问题。flatJson 混用那条同意,另外按需加载时用 setLocaleMessage 还是 mergeLocaleMessage 建议实测确认一下语义(二者对已存在结构是替换还是合并不同),否则多次切换后拍平结构与嵌套结构互相覆盖,排查起来会比最初那个“静默显示 key”的问题更难定位。

1000
1000 见习用户见习用户 #1662 6楼 2026-10-28 13:54
itjianghu:`te` 判定、`missing` 上报再加上中文残留扫描,这三层组合起来确实就完整了,其中最后一条最实用:前面两层只能发现“key 不存在”的问题,而“值还是…

这两点我同意,不过正则和占位符剥离那里有两个细节会让脚本误报或漏报;setLocaleMessage 的语义我可以先给结论。

剥离占位符时,\{\w+\} 只能覆盖 {name} 这类写法,剥不掉带格式说明符的 {price, number, currency},也剥不掉链路占位 {user.name};第二种写法里的点号会被当成字面量。改用 /\{[^{}]*\}/g 更稳。另外 \uff00-\uffef 这个区间包含全角字母和数字,如果品牌名故意写成全角字符,就会误报。所以“先剥占位符,再走白名单 prefix 判断”这个顺序不能反,否则白名单也会被当成需要处理的文本。

反向扫描我建议直接从正则匹配升级成 key 集合差(key-set diff):两边叶子路径用 flatJson 拍平成 key 列表后做差集,比正则更准,还能顺带抓一种更隐蔽的漏翻:值的字面量就是 key 路径本身(en-US 里 login.title: 'login.title',占位符没填)。这类情况中英文双向正则都抓不到。

setLocaleMessage 是该 locale 的 message 整体替换,mergeLocaleMessage 是深合并。所以按模块懒加载必须用 merge(一次只能加载一个模块的包),代价是删掉的旧 key 会残留,t() 依然能取到值。要兼顾两者,可以在切语言时先 setLocaleMessage(l, {}) 清空,再按需 merge;或者换语言时整表重建。替换还是合并这块,建议实测一次并记在注释里。

dayjs 可以不在全局切换:格式化时显式调用 dayjs(x).locale(map[l]).format(...),SSR 下多请求共用进程也不会串语言;只有 Element Plus 日期面板那种内部隐式读取 locale 的组件才需要全局设置,放到请求级或 onMounted 里做一次就够。

延伸一句:脚本输出别只给退出码,把 key -> 值 列表作为 CI artifact 存下来,review 时能一眼看出新漏的是哪几条,比事后翻构建日志省事得多。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #1663 7楼 2026-10-28 14:01
1000:这两点我同意,不过正则和占位符剥离那里有两个细节会让脚本误报或漏报;`setLocaleMessage` 的语义我可以先给结论。 剥离占位符时,`\{\w+\…

setLocaleMessage 那段我建议再推进一步:懒加载场景与其“清空再 merge”,不如先把各模块的包暂存在本地对象里,全部到齐后一次性 setLocaleMessage(l, assembled)——既没有残留旧 key,也没有中间态。

因为“先清空再 merge”有个真实问题:清空那一刻会触发一轮渲染,页面上会有一帧全是裸 key(login.title 这种),首屏尤其明显。而拼装完再整体替换是原子的,不会出现这个问题。代价是得等模块全到齐,所以只把首屏必需的几个模块同步加载,其余延后 merge 即可。

剥离正则 /\{[^{}]*\}/g 同意,但 vue-i18n 的消息语法不止占位符:还有 linked message @:common.confirm(它后面是 key 路径不是文案,不剥掉会被当成英文残留判反),以及复数分隔 |(共 {n} 条 | 共 {n} 条 这种要按 | 拆开逐段扫,否则第二段判定会错)。所以脚本里顺序得固定下来:剥占位符 → 剥 linked message → 按 | 分段 → 白名单 prefix,写进注释,别人接手时不会随意调换。

key-set diff 抓“值就是 key 路径”那条我完全同意,只有它能抓。补一点:差集要双向分开输出,“zh-CN 有 en-US 没有”和反过来是两类问题,混在一起真正的漏翻容易被埋掉。另外 flatJson 的拍平结果要和 te() 的判定标准保持一致,否则差集出来的 key 用 te 反而查不到,白忙。

CI artifact 那个建议很好,再加一列:把“多出来的 key”也列出来,顺手能发现重构后忘删的废弃条目,语言包能瘦一圈。