Vite 国际化与多语言构建:按语言分包、懒加载与回退策略

在 Vite 项目中落地国际化的完整方案:文案、格式与路由三层拆分、JSON 命名空间与键设计、运行时按语言懒加载与回退链、基于动态导入的 locale 分包、Intl 与 ICU 的复数与格式化、URL 语言前缀与切换、构建期内联默认语言与预加载,以及回退缺失与首屏闪烁等高频陷阱。

引言

国际化(i18n)在 Vite 项目里不只是「换个文案」。它牵扯三层:文案的组织与加载、数字日期货币的格式化、以及 URL 与路由上的语言前缀。做得好,多语言包按需加载、切换无闪烁;做得差,所有语言文案打进首屏 bundle,或者切换语言后整页刷新白屏。

本文从国际化的三层拆分讲起,深入文案的命名空间与键设计、按语言的动态导入与分包、回退链的构造、Intl 与 ICU 的格式化,最后给出构建期优化手段与常见陷阱的排查清单。

前置:代码分割与产物优化、运行期加载策略。SSR 场景见 Vite SSR 与服务端渲染实战:从模块图到全栈框架生态。


目录


1. 国际化的三层:文案、格式与路由

1.1 三层的职责

国际化拆开看是三个相对独立的子系统,混在一起做必然乱:

层职责关键决策
文案键值翻译、命名空间结构与加载时机
格式数字、日期、货币、复数用 Intl 还是库
路由语言前缀、切换与持久化URL 结构

1.2 为什么不建议一把梭

最常见的反模式是把所有语言的 JSON 一次性 import 进入口,结果首屏 bundle 里躺着五六种语言。文案必须能按语言拆开、按需加载,而这正好是 Vite 动态导入的强项。

反模式:import zh from './zh.json'; import en from './en.json' ...
正解:  动态 import(`./locales/${lang}.json`)

记忆:国际化拆成文案、格式、路由三层——文案要按语言动态加载,别把五六种语言的 JSON 全打进首屏。


2. 文案组织:JSON 结构、命名空间与键设计

2.1 目录结构

按语言分目录、按命名空间分文件,是扩展性最好的组织方式:

src/locales/
  zh-CN/
    common.json      → 通用按钮、提示
    home.json        → 首页专属
    errors.json      → 错误码文案
  en-US/
    common.json
    home.json
    errors.json

2.2 键设计原则

- 用「语义」而不是「原文」做键:common.save 而不是 common.保存
- 层级不超过三层,过深难维护
- 复数、插值等特殊结构用统一的约定(如 _one / _other)
- 键一旦发布就不要改名,改名等于所有语言都要改

2.3 示例

{
  "save": "保存",
  "greeting": "你好,{{name}}",
  "cart_items_one": "购物车有 {{count}} 件商品",
  "cart_items_other": "购物车有 {{count}} 件商品"
}

记忆:文案按语言分目录、按命名空间分文件——键用语义命名且发布后不改名,插值与复数用统一约定表达。


3. 运行时加载:按语言懒加载与回退

3.1 用 i18next 做懒加载

// src/i18n.ts
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'

i18n.use(initReactI18next).init({
  lng: localStorage.getItem('lang') ?? 'zh-CN',
  fallbackLng: 'en-US',
  ns: ['common'],
  defaultNS: 'common',
  interpolation: { escapeValue: false },
})

export default i18n

3.2 用 Vite 的动态导入喂资源

关键点是把语言文件交给 Vite 的 import.meta.glob 管理,这样每个语言会成为独立 chunk:

const modules = import.meta.glob('./locales/*/*.json')

async function loadNamespace(lng: string, ns: string) {
  const loader = modules[`./locales/${lng}/${ns}.json`]
  const mod = await loader()
  return mod.default
}

3.3 回退链

回退要分两级:先回退到同语言的其他命名空间,再回退到默认语言。缺失的键应当在开发期就报错,而不是运行时静默显示键名。

zh-CN.home.title 缺失 → 回退 en-US.home.title
整份 zh-CN 未加载     → 回退 en-US

记忆:按语言懒加载的核心是把语言文件交给 import.meta.glob——回退链分「同语言其他命名空间」与「默认语言」两级,缺失键要在开发期暴露。


4. 按语言分包:构建产物拆分策略

4.1 分包的自然结果

因为每种语言是独立的动态导入,Vite 会自动为每种语言生成独立 chunk:

dist/assets/
  zh-CN-common-a1b2c3.js
  en-US-common-d4e5f6.js
  首页 chunk(不包含任何语言文案)

4.2 默认语言的处理

默认语言有两种策略,各有利弊:

策略做法适用
内联默认语言默认语言同步打进主包首屏就要文案
全部懒加载默认语言也动态导入首屏可接受短暂加载

4.3 避免语言包互相污染

要确保语言文件之间不共享可变状态,否则切换语言时可能出现缓存串味。每次 loadNamespace 都应重新取模块,而不是把结果挂到全局可变对象上。

记忆:每种语言的动态导入天然生成独立 chunk——默认语言可选内联(首屏即用)或懒加载(更小首屏),但语言模块之间不能共享可变状态。


5. 复数与格式化:Intl API 与 ICU 消息

5.1 优先用原生 Intl

浏览器的 Intl 覆盖了数字、货币、日期、相对时间与复数规则,不需要额外依赖:

new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(1234.5)
// ¥1,234.50

new Intl.DateTimeFormat('en-US', { dateStyle: 'long' }).format(new Date())
// October 2, 2026

5.2 复数规则

不同语言的复数规则差异很大(英语 2 类、阿拉伯语 6 类),不要自己写 count === 1 ? ... : ...:

const pr = new Intl.PluralRules('en-US')
const form = pr.select(3)   // 'other'
// 用 form 去选 cart_items_one / cart_items_other

5.3 ICU 消息格式

复杂插值(如「{count, plural, …}」)可以用 ICU 语法:

{
  "items": "{count, plural, =0 {没有商品} one {# 件商品} other {# 件商品}}"
}

配合支持 ICU 的格式化器(如 intl-messageformat)解析。注意:ICU 会增加运行时体积,简单场景优先用 Intl.PluralRules 手写映射。

记忆:格式化优先用原生 Intl——复数别手写三元表达式,用 Intl.PluralRules 选形态;ICU 消息更表达力强但更重,简单场景不必上。


6. 路由与语言前缀:URL 结构与切换

6.1 URL 结构选择

结构示例特点
路径前缀example.com/en/homeSEO 友好、可分享
子域名en.example.com部署复杂
查询参数example.com?lang=en最简、SEO 差

推荐路径前缀,对搜索引擎与分享最友好。

6.2 切换语言

切换语言时要避免整页刷新:

async function switchLang(lng: string) {
  await i18n.changeLanguage(lng)
  localStorage.setItem('lang', lng)
  // 若用路径前缀,则同步更新 URL 而不 reload
  history.replaceState(null, '', `/${lng}${location.pathname.replace(/^\/[a-z-]+/, '')}`)
}

6.3 与 Vite 的 base 配合

如果站点部署在子路径(base: '/app/'),语言前缀要拼在 base 之后,否则路由会错位。

记忆:语言前缀用路径形式最利于 SEO——切换语言用 changeLanguage 加 history.replaceState,不要整页刷新;子路径部署要拼在 base 之后。


7. 动态导入与代码分割:locale chunk 的生成

7.1 命名与缓存

给语言 chunk 起可预测的名字,便于长期缓存:

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: (chunk) =>
          chunk.name.includes('locale')
            ? 'assets/locales/[name]-[hash].js'
            : 'assets/[name]-[hash].js',
      },
    },
  },
})

7.2 预加载下一门语言

在用户很可能切换的语言上做预取:

// 用户悬停语言切换按钮时预取
function prefetchLang(lng: string) {
  const link = document.createElement('link')
  link.rel = 'prefetch'
  link.href = `/assets/locales/${lng}-common.js`
  document.head.appendChild(link)
}

7.3 控制 chunk 数量

语言多时 chunk 会爆炸。可以按「语言组」合并(如 zh-CN 与 zh-TW 共享一份基础文案),或者只对高频语言做独立 chunk。

记忆:locale chunk 用可预测命名便于长缓存——对高频语言做 prefetch 预取,语言很多时按语言组合并以避免 chunk 爆炸。


8. 构建期优化:内联默认语言与预加载

8.1 内联默认语言

如果首屏强依赖文案,把默认语言同步内联进主包,避免一次额外往返:

import zhCommon from './locales/zh-CN/common.json'

i18n.init({
  lng: 'zh-CN',
  resources: { 'zh-CN': { common: zhCommon } },
})

8.2 用 HTML 注入预加载

可以在 index.html 里预加载默认语言资源,让浏览器尽早发起请求:

<link rel="preload" href="/assets/locales/zh-CN-common.js" as="script" crossorigin />

8.3 体积预算

- 单语言单命名空间 ≤ 30KB(gzip 后 ≤ 10KB)
- 首屏内联文案 ≤ 15KB
- 语言 chunk 总数控制在 20 个以内

记忆:首屏强依赖文案就把默认语言内联、其余懒加载——单语言单命名空间控制在 gzip 后 10KB 以内,语言 chunk 总数别失控。


9. 常见陷阱:回退缺失、闪烁与 SSR 不一致

9.1 高频陷阱

现象原因处理
页面显示键名该键缺失且无回退开发期报错 + 回退链
切换语言白屏切换时整页刷新用 changeLanguage 无刷新
首屏文案闪烁文案异步到达晚于渲染内联默认语言
SSR 与客户端不一致服务端语言判定不同用同一份语言判定逻辑
语言包串味模块被缓存复用不共享可变状态
chunk 过多每种语言都独立按语言组合并

9.2 首屏闪烁的根因

闪烁的本质是「文案还没到,页面已经渲染」。解决办法只有两个:要么把默认语言内联进主包,要么在文案就绪前渲染骨架屏。

9.3 SSR 的一致性

SSR 下必须保证服务端与客户端用同一套语言判定逻辑(同一份 Cookie 或 Accept-Language 解析),否则水合时会因为文案不同而报不匹配。

记忆:i18n 翻车集中在「缺键、闪烁、SSR 不一致」——缺键在开发期报错、闪烁靠内联默认语言、SSR 靠服务端与客户端共用一套语言判定。


10. 工程规范与协作流程

10.1 协作流程

1. 开发只写默认语言的键与文案
2. 提交流程中抽取新增键,生成待翻译清单
3. 翻译平台回填其他语言
4. CI 校验:所有语言键集合是否一致、有无缺失

10.2 CI 校验脚本思路

// 校验各语言键集合一致性
const base = Object.keys(zhCN)
for (const lang of others) {
  const diff = base.filter((k) => !(k in lang))
  if (diff.length) throw new Error(`${lang} 缺失键: ${diff}`)
}

10.3 落地清单

□ 文案按语言 + 命名空间组织
□ 语言文件走 import.meta.glob 动态加载
□ 回退链:同语言其他命名空间 → 默认语言
□ 格式化统一用 Intl,复数用 PluralRules
□ URL 用路径前缀,切换不刷新
□ 默认语言内联,其余懒加载
□ CI 校验各语言键集合一致
□ 监控语言 chunk 体积

记忆:i18n 的工程化落点是「键集合一致 + 按语言分包 + 回退可控」——把键校验放进 CI、把默认语言内联、把其余语言懒加载,多语言就不再是维护负担。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「vite」更多文章

  1. Vite 中的 3D 与 WebGL 工程化:Three.js、模型纹理压缩与渲染性能治理
  2. Vite 项目的 GraphQL 数据层:Apollo、urql、codegen 与缓存失效实战
  3. Vite 项目部署平台适配实战:Vercel、Netlify、Cloudflare Pages 与自建方案