白屏、闪回与半中半英
用户在语言菜单连点了两次:先点了 English,马上改主意点回中文。300ms 后界面先切成英文,又"闪回"中文;或者更糟——导航是英文、表格列头是中文,半中半英。弱网下切换还会白屏转圈半天,失败后界面停在一种说不清的状态。根因不复杂:语言包是异步 chunk,“切换语言"是一次异步状态迁移,但代码把它写成了一个同步赋值。
竞态的根子:谁后返回谁生效,而不是谁后点击谁生效
// 经典错误写法
async function changeLocale(locale: string) {
const messages = await loadMessages(locale) // 网络往返,耗时不可控
i18n.global.setLocaleMessage(locale, messages)
i18n.global.locale.value = locale
}
连点两次,zh 到 en 的包走得慢、en 到 zh 的包走得快:zh 先激活、en 后激活,最终界面是英文,而用户最后点的是中文。这不是 vue-i18n 的 bug,是我们没做"最后一次意图优先”。完整的解法是一个带版本号守卫的小状态机:
// composables/useLocaleSwitch.ts
import { shallowRef } from 'vue'
import { i18n } from '@/i18n'
import { localeLoaders, DEFAULT_LOCALE, type Locale } from '@/i18n/loaders'
const current = shallowRef<Locale>(DEFAULT_LOCALE)
const target = shallowRef<Locale>(DEFAULT_LOCALE) // 最近一次请求的语言,重试用
const loading = shallowRef(false)
const error = shallowRef<Error | null>(null)
const messageCache = new Map<Locale, Record<string, unknown>>()
const inflight = new Map<Locale, Promise<Record<string, unknown>>>()
let seq = 0 // 切换版本号:过期结果一律丢弃
async function loadMessages(locale: Locale) {
const cached = messageCache.get(locale)
if (cached) return cached
let p = inflight.get(locale)
if (!p) {
p = localeLoaders[locale]().then((m) => m.default)
inflight.set(locale, p) // 并发请求同一语言只下载一次
}
try {
const messages = await p
messageCache.set(locale, messages)
return messages
} finally {
inflight.delete(locale) // 失败不缓存,重试才有意义
}
}
async function switchLocale(locale: Locale) {
const id = ++seq
target.value = locale
error.value = null
loading.value = true
try {
const messages = await loadMessages(locale)
if (id !== seq) return // 已不是最后一次点击,丢弃结果
i18n.global.setLocaleMessage(locale, messages)
i18n.global.locale.value = locale // 与上一行同 tick:一次渲染完成切换
current.value = locale
document.documentElement.lang = locale
} catch (e) {
if (id !== seq) return
error.value = e instanceof Error ? e : new Error(String(e))
} finally {
if (id === seq) loading.value = false // 旧请求不能提前关掉新请求的 loading
}
}
// 语言菜单 hover 时预取,点下去基本命中缓存
function preload(locale: Locale) { void loadMessages(locale).catch(() => {}) }
export function useLocaleSwitch() {
return { current, target, loading, error, switchLocale, preload }
}
几个细节:seq 守卫拦掉所有过期激活,catch 分支同样先看 id——失败也只对"最后一次点击"负责,界面停留在当前语言,绝不半新半旧;loading 的清空带 id 守卫,避免旧请求把新请求的加载态提前结束;inflight 去重解决"连点同一语言下载三次";messageCache 让第二次切换零网络。切换入口的文案用静态映射(别在模板里 new Intl.DisplayNames),失败重试直接复用 target:
<template>
<div class="lang-menu">
<button v-for="l in supported" :key="l" :disabled="loading"
@pointerenter="preload(l)" @click="switchLocale(l)">
{{ labels[l] }}
</button>
<p v-if="error">
{{ t('common.localeLoadFailed') }}
<button @click="switchLocale(target)">{{ t('common.retry') }}</button>
</p>
</div>
</template>
加载期间禁用按钮只是体验层防线,真正的保证是版本号守卫——慢网络下用户绕过 UI 直接触发切换也不会翻车。
缺失 key:开发环境大声报,生产环境安静回退
// i18n/index.ts
import { createI18n } from 'vue-i18n'
import zhCN from './locales/zh-CN.json'
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'zh-CN',
missingWarn: import.meta.env.DEV, // 开发环境:缺 key 控制台告警
fallbackWarn: import.meta.env.DEV, // 生产环境:静默走回退链
messages: { 'zh-CN': zhCN },
})
回退链的代价是"英文界面里夹中文"这种脏状态,不能只靠 missingWarn 在开发机自愈,CI 里做各语言 key 集合对齐:
// scripts/check-i18n-keys.mjs —— key 集合不一致则 CI 失败
import { readFileSync, readdirSync } from 'node:fs'
function flatten(obj, prefix = '', out = new Set()) {
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
v !== null && typeof v === 'object' ? flatten(v, key, out) : out.add(key)
}
return out
}
const base = flatten(JSON.parse(readFileSync('src/locales/zh-CN.json', 'utf8')))
const problems = []
for (const f of readdirSync('src/locales')) {
if (f === 'zh-CN.json') continue
const keys = flatten(JSON.parse(readFileSync(`src/locales/${f}`, 'utf8')))
for (const k of base) if (!keys.has(k)) problems.push(`${f} 缺失: ${k}`)
for (const k of keys) if (!base.has(k)) problems.push(`${f} 多出: ${k}`)
}
if (problems.length) { console.error(problems.join('\n')); process.exit(1) }
切换时机:整页一次翻转,副作用集中处理
半中半英的另一半来源是"切了一半":先 set 一部分文案、await 点别的、再改 locale,两次渲染 flush 之间就是混合态。约定只有一种切换方式——语言包齐了才动手,setLocaleMessage 与 locale.value 赋值之间一个 await 都不留,整页在一个渲染周期内翻转。loading 期间盖一层轻量遮罩或顶部进度条,别让用户对着旧语言干等、以为没点上。逐组件各自懒加载文案的"渐变切换"看起来优雅,实际把混合态变成常态,管理后台这种要求整页一致的场景不要用。
app 根之外的文案要跟着一起切:document.documentElement.lang 已在 switchLocale 里同步更新(读屏器和字体回退都依赖它),document.title 用路由钩子集中处理,路由变化时用"当前"语言重算,而不是只在切换那一刻算:
const routeTitles: Record<string, Record<Locale, string>> = {
'/tasks': { 'zh-CN': '任务列表', 'en-US': 'Tasks', 'ja-JP': 'タスク' },
}
router.afterEach((to) => {
document.title = routeTitles[to.path]?.[current.value] ?? 'Console'
})
打包:默认语言进主包,其他懒加载加内容寻址缓存
// i18n/loaders.ts
import zhCN from './locales/zh-CN.json'
export const DEFAULT_LOCALE = 'zh-CN'
export type Locale = 'zh-CN' | 'en-US' | 'ja-JP'
export const localeLoaders: Record<Locale, () => Promise<{ default: Record<string, unknown> }>> = {
// 默认语言静态 import:进主包,首屏零请求
'zh-CN': () => Promise.resolve({ default: zhCN }),
// 其余动态 import:Vite 拆成带内容 hash 的独立 chunk
'en-US': () => import('./locales/en-US.json'),
'ja-JP': () => import('./locales/ja-JP.json'),
}
hash 文件名意味着内容变则文件名变,服务端可以对 /assets/ 路径放心回 Cache-Control: public, max-age=31536000, immutable:发版改了文案文件名就变,不存在"用户拿着旧缓存"的问题。反例是给语言包加时间戳查询串——每次都缓存 miss,等于没做缓存。
Intl 实例要缓存,日期与复数各有坑
Intl 构造要做 locale 数据解析和选项规范化,不便宜。表格 40 行乘两列时间,如果每次渲染都 new formatter,这个成本会直接出现在 Performance 面板里;缓存后是纯查表。toLocaleString 带自定义 options 的调用也别指望引擎帮你缓存,自己包一层(缓存 key 带 locale,语言切换后自然隔离,不需要主动清理):
const cache = new Map<string, Intl.DateTimeFormat>()
export function formatDateTime(locale: Locale, ts: number) {
const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' }
const key = `${locale}|${JSON.stringify(opts)}`
let fmt = cache.get(key)
if (!fmt) cache.set(key, (fmt = new Intl.DateTimeFormat(locale, opts)))
return fmt.format(ts)
}
复数的坑在于"英文思维写复数"。vue-i18n 用竖线分隔复数形式,t(key, count) 按语言选择:
// en-US.json
{ "tasks": { "remaining": "no tasks left | one task left | {count} tasks left" } }
// zh-CN.json
{ "tasks": { "remaining": "还剩 {count} 个任务" } }
模板里 t('tasks.remaining', count) 即可,英文自动按 0/1/多选段,中文恒走唯一一条。俄语这类语言的复数类别不止三段,默认规则不够用,要按语言注册:
createI18n({
pluralizationRules: {
'ru-RU'(choice: number, choicesLength: number) {
if (choice === 0) return 0
const teen = choice % 100 >= 11 && choice % 100 <= 14
const endsWithOne = choice % 10 === 1
if (!teen && endsWithOne) return 1
if (!teen && choice % 10 >= 2 && choice % 10 <= 4) return 2
return choicesLength < 4 ? 2 : 3
},
},
})
日期还有一个跨时区暗坑:date-only 字符串按 UTC 解析,带时间无时区的字符串按本地时区解析——new Date('2026-10-24') 是 UTC 零点,new Date('2026-10-24T00:00:00') 是本地零点。后端返回 date-only 字符串时,东八区用户格式化出的"10月24日"在别的时区可能是"10月23日"。要么统一存完整时间戳加显式 timeZone 选项,要么在解析处钉死时区,别留给运气。
常见坑点
1. 用 debounce 防"连点"
延迟只是把竞态窗口压小,慢网络下照样翻车。版本号守卫才是解,debounce 顶多省一次下载。
2. 语言包走不带头 hash 的接口
缓存策略形同虚设,每次发版全量重新拉。
3. fallback 回退没人盯
en 缺 key 回退到 zh,英文界面夹中文。CI 做 key 集合对齐,别靠人肉巡检。
4. loading 期间不禁用切换入口
连点常态化,竞态守卫天天在救火。禁用加 hover 预取才是体验正解。
总结
- 切换是异步状态迁移:版本号守卫保证最后一次意图生效,缓存与 inflight 去重保证不重复下载
- 失败留在当前语言,显式提示加重试;缺失 key 开发环境大声报、CI 对齐集合、生产安静回退
- 默认语言进主包,其余带 hash 懒加载,hover 预取抹平点击延迟;Intl 实例按 locale 缓存,复数规则按语言注册,date-only 解析钉死时区
语言切换是一个状态机,不是一个赋值语句。