白屏、闪回与半中半英

用户在语言菜单连点了两次:先点了 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 解析钉死时区

语言切换是一个状态机,不是一个赋值语句。