前端国际化工程实践:语言包拆分、动态加载与日期数字格式统一

0 阅读1分钟

前端国际化工程实践:语言包拆分、动态加载与日期数字格式统一

国际化工程的难点,通常不在于把 Hello 替换成 你好,而在于应用规模增长后,如何同时保证:

  • 多语言资源不会拖慢首屏;
  • 路由切换和语言切换不会闪烁、串语言或重复请求;
  • SSR 与客户端 hydration 不会因为 locale、时区不同而产生内容不一致;
  • 日期、金额、百分比等格式不再散落在业务组件中;
  • 翻译键、变量、复数规则与发布流程能够持续治理。

本文以中大型 CSR 应用为主场景,同时补充 SSR/SSG 的一致性要求。具体实现可使用 React + i18next、Vue + Vue I18n、Angular 的国际化方案或自研封装;重点不依赖某一个库,而是资源边界、加载状态和格式化边界。

先拆开几个经常被混用的概念

国际化配置不应只保留一个 locale 字段。至少应区分以下上下文:

概念示例决定什么
UI localezh-CNfr-CA界面文案、日期和数字的展示习惯
内容语言enja商品描述、帮助文章等内容本身的语言版本
业务地区USDE可售商品、税务、合规文案、配送能力
货币代码USDEURJPY金额含义与货币格式化参数
IANA 时区Asia/ShanghaiAmerica/New_York某个时间应如何显示

locale 可以包含语言、地区和书写系统等信息,例如 zh-Hantfr-CA;但它不等于货币,也不等于事件发生地时区。不要因为用户选择了 en-US,就隐式假定金额一定是美元、时间一定按纽约时区显示。这样的隐式推导会在跨境、多门店或多租户产品中迅速失效。

展示 UI locale、内容语言、业务地区、货币代码和 IANA 时区是相互关联但不可互相推导的独立上下文。

一、语言包拆分:以加载边界和治理边界为准

推荐基础模型:locale × namespace

语言资源建议先采用二维模型:每种语言都有一组命名空间(namespace),每个命名空间对应一个可独立加载、独立治理的资源单元。

src/
└── locales/
    ├── en-US/
    │   ├── common.json
    │   ├── validation.json
    │   ├── account.json
    │   ├── checkout.json
    │   └── pages/
    │       ├── home.json
    │       └── orders.json
    └── zh-CN/
        ├── common.json
        ├── validation.json
        ├── account.json
        ├── checkout.json
        └── pages/
            ├── home.json
            └── orders.json

其中:

  • common:跨页面高频复用的按钮、通用操作、状态文案;
  • validation:表单校验、错误码和输入提示;
  • 领域 namespace:如 accountcheckoutinventory
  • 页面或路由 namespace:只在特定页面使用、体积可能较大的文案;
  • 租户维度仅在确有白标、品牌术语或合规文案差异时增加,例如 tenant/{tenantId}/{locale}/{namespace}.json

i18next 将 namespace 作为多翻译文件和按需加载的资源边界;Vue I18n 也支持通过动态 import() 异步加载 locale 消息。两者都说明:语言资源不必在启动时一次性进入主包。

不要机械地“组件级拆包”

把每个微型组件都变成独立语言包,通常得不偿失:请求数、依赖关系、回退逻辑、发布协调和缓存碎片都会增加。

更稳妥的拆分顺序是:

  1. 先按全局共享、业务域、路由页面划分;
  2. 当某个 namespace 体积明显偏大,或只被少量异步模块使用时,再继续拆分;
  3. 让一个 namespace 对应相对稳定的产品边界,而不是某个组件的物理目录。

可以把它理解为:namespace 首先是资源交付单元内容治理单元,其次才是代码组织方式。

键名必须表达语义,而不是复制源文案

不推荐:

{
  "Submit order": "提交订单"
}

推荐:

{
  "order": {
    "submit": "提交订单",
    "submitPending": "正在提交订单…",
    "submitFailed": "订单提交失败,请重试"
  }
}

语义键的优势是源语言文案调整时不必修改业务代码,也便于做跨语言键集合校验。每个键还应维护以下元数据:

  • 使用场景与截图或页面路径;
  • 插值变量的名称、类型和含义;
  • 是否允许富文本;
  • 是否废弃,以及废弃版本。

对于复杂文案,资源模型要能表达插值、选择分支和复数,而不能只支持静态字符串。复数规则并不只有英文式的单数和复数;Unicode 复数规则包含 zeroonetwofewmanyother 等类别,实际命中类别取决于 locale。

{
  "cart": {
    "itemCount": "{count, plural, =0 {购物车为空} one {# 件商品} other {# 件商品}}"
  }
}

这里的重点不是强制使用某一种 ICU 语法,而是让翻译系统、运行时能力和校验工具共同理解:count 是必填变量,且该消息具有复数分支。

二、动态加载:把“资源就绪”变成明确状态

语言包加载至少有四个触发点:

  1. 应用启动:加载默认 locale 的核心 namespace,例如 commonvalidation
  2. 进入路由前:加载目标路由需要的页面或领域 namespace;
  3. 语言切换时:加载目标 locale 下当前页面正在使用的资源集合;
  4. 预测预加载:对高概率进入的下一页,或用户可能切换到的语言,在空闲时间预加载。

路由级加载优先于组件级加载

路由通常是最合适的首层加载边界:它既能在页面渲染前完成资源准备,也便于与路由代码分割、权限校验和数据预取统一编排。

type Locale = 'zh-CN' | 'en-US' | 'ja-JP'
type Namespace = 'common' | 'validation' | 'checkout' | 'pages/orders'

async function beforeEnterOrders(locale: Locale) {
  await ensureNamespaces(locale, ['common', 'pages/orders'])
}

ensureNamespaces 不应只是简单的网络请求包装,而应具备:

  • 已加载资源的内存缓存;
  • 同一个 locale + namespace 的 in-flight Promise 去重;
  • 可版本化的 CDN 或构建产物地址;
  • 超时、重试和失败记录;
  • 可选的预加载优先级。
const pending = new Map<string, Promise<void>>()
const loaded = new Set<string>()

function resourceKey(locale: string, ns: string) {
  return `${locale}:${ns}`
}

async function ensureNamespace(locale: string, ns: string) {
  const key = resourceKey(locale, ns)
  if (loaded.has(key)) return
  if (pending.has(key)) return pending.get(key)

  const task = import(`./locales/${locale}/${ns}.json`)
    .then((module) => {
      registerMessages(locale, ns, module.default)
      loaded.add(key)
    })
    .finally(() => pending.delete(key))

  pending.set(key, task)
  return task
}

实际工程中还应确认构建工具对动态导入路径的解析规则。若 locale 和 namespace 都完全动态,通常需要通过显式导入映射、import.meta.glob 或构建工具提供的等价机制,让打包器能够识别可生成的资源集合。

语言切换的原则:先准备,再提交

异步加载中最常见的问题是:用户已经选择了日语,但日语包尚未加载完成,页面先显示翻译键、默认语言,甚至残留上一种语言。

正确的状态顺序应是:

请求切换语言
  → 计算当前页面所需 namespace
  → 加载目标 locale 资源
  → 注册资源
  → 原子性提交 activeLocale
  → 更新 <html lang>、请求头和持久化设置

不要在资源未就绪时立即修改 activeLocale。Vue I18n 的官方懒加载示例同样采用“先异步加载并注册消息,再设置 locale”的顺序。

展示语言切换从请求、计算所需命名空间、加载与去重、注册资源,到校验最新请求后提交 activeLocale 的流程,以及失败和过期请求的分支。

处理竞态、闪烁和失败降级

当用户快速从 zh-CN → en-US → ja-JP 切换时,第一个请求可能最后才返回。若没有保护,旧请求会覆盖最新选择。

可采用两种策略:

  • 请求序号:仅允许最后一次请求提交 locale;
  • AbortController:对可取消的 HTTP 请求中止旧请求。
let switchVersion = 0

async function changeLocale(nextLocale: Locale) {
  const version = ++switchVersion
  const namespaces = getNamespacesForCurrentRoute()

  await Promise.all(namespaces.map((ns) => ensureNamespace(nextLocale, ns)))

  if (version !== switchVersion) return
  commitLocale(nextLocale)
}

用户可见的降级策略应分层:

  • 路由首次进入:显示页面级 skeleton,而不是翻译键;
  • 某个低优先级模块加载中:显示局部占位区域;
  • 资源加载失败:保留当前已完整可用语言,提示用户重试,不要把半翻译页面提交为成功状态;
  • 翻译键缺失:开发和测试环境可显眼展示键名;生产环境应使用明确回退语言,同时上报错误。

三、回退链与缺失键:必须显式设计

语言回退不应依赖库的默认行为。需要明确:支持哪些 locale、地区变体如何回退、最终产品默认语言是什么,以及 namespace 缺失时是否允许回退到 common

例如:

const localePolicy = {
  supported: ['en-US', 'zh-CN', 'zh-TW', 'ja-JP'],
  fallbackChain: {
    'zh-TW': ['zh-TW', 'en-US'],
    'en-US': ['en-US'],
    default: ['en-US']
  },
  fallbackNamespace: ['common']
}

回退链中的每一个 locale 都应有可实际加载的资源,或由运行时明确支持其资源别名。不要在配置中加入不存在的中间 locale,否则回退过程只会额外产生失败请求和不可预测行为。

需要注意:语言学上的回退链和产品策略并不总是相同。比如某个市场可能要求无法翻译时回退到当地法定语言,而不是全球英文。因此,回退链应是产品配置,而非开发者的临时判断。

缺失键治理至少包含三道防线:

  1. CI 静态校验:比较基准语言与目标语言的键集合,校验插值变量、复数分支和不合法消息;
  2. 运行时采集:记录 localenamespacekey、路由、版本和调用栈;
  3. 指标告警:关注缺失键率,而不是只在浏览器控制台打印日志。

i18next 提供了缺失键和缺失插值的处理钩子,可用于接入日志或监控系统;无论使用哪个库,都应将“缺失翻译”作为可观测的生产质量问题。

四、SSR/SSG:服务端和客户端必须共享首屏事实

SSR/SSG 场景下,国际化问题会从“加载慢”升级为“hydration 不一致”。常见原因包括:

  • 服务端依据请求头解析出 fr-CA,客户端却从本地存储恢复为 en-US
  • 服务端渲染时使用 UTC,客户端格式化时使用用户设备时区;
  • 服务端加载了首屏 dictionary,客户端初始化时没有复用同一份资源。

因此,首屏至少要共享三类事实:

  1. 已解析的 locale
  2. 首屏已使用的 namespace 与其资源版本;
  3. 参与首屏格式化的时区策略。

在 Next.js App Router 一类架构中,可以根据请求中的语言偏好和应用支持的 locale 确定语言,并在服务端加载 dictionary。Server Component 中使用的翻译资源不会作为客户端 JavaScript 模块进入浏览器包;但如果首屏包含需要在客户端继续交互的翻译组件,客户端仍需要以一致的 locale 和初始资源完成初始化。

实践上可以把服务端结果序列化为初始国际化状态:

interface InitialI18nState {
  locale: string
  timeZone: string
  resources: Record<string, unknown>
  resourceVersion: string
}

客户端先用这份状态 hydration,再加载后续路由资源。不要让客户端在 hydration 期间重新猜测 locale 或时区。

五、统一格式化层:页面不应直接手写 Intl 参数

Intl 提供了 locale-sensitive 的日期时间、数字、货币、单位、相对时间、列表和复数规则能力。它应成为前端格式化的基础,但不意味着每个业务组件都可以自由组合 Intl options。

以下写法看似简单,却会把产品规范分散到所有页面:

new Intl.NumberFormat(locale, {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 2
}).format(amount)

问题在于:另一个页面可能使用不同的小数位、不同的货币展示规则,或忘记传 locale。应建立一个受控的格式化门面,提供有限、具名的格式预设。

interface FormatContext {
  locale: string
  displayTimeZone: string
}

export function createFormatter(ctx: FormatContext) {
  return {
    dateShort(value: Date | number) {
      return new Intl.DateTimeFormat(ctx.locale, {
        dateStyle: 'short',
        timeZone: ctx.displayTimeZone
      }).format(value)
    },

    eventDateTime(value: Date | number, timeZone: string) {
      return new Intl.DateTimeFormat(ctx.locale, {
        dateStyle: 'medium',
        timeStyle: 'short',
        timeZone,
        timeZoneName: 'short'
      }).format(value)
    },

    decimal(value: number) {
      return new Intl.NumberFormat(ctx.locale, {
        maximumFractionDigits: 2
      }).format(value)
    },

    percent(value: number) {
      return new Intl.NumberFormat(ctx.locale, {
        style: 'percent',
        maximumFractionDigits: 1
      }).format(value)
    },

    money(value: number, currency: string) {
      return new Intl.NumberFormat(ctx.locale, {
        style: 'currency',
        currency
      }).format(value)
    }
  }
}

金额格式化与金额计算应分层处理。Intl.NumberFormat 负责展示;金额的存储、计算和舍入则应遵循业务精度规则,避免把 JavaScript 二进制浮点数误差直接带入财务计算。货币的小数位也不应一律写死为 2,应由货币代码的默认规则或明确的业务规则决定。

推荐把预设命名为产品语义,而不是技术选项:

预设使用位置关键约束
date.short列表日期只显示日期,不显示时间
dateTime.event会议、预约、直播必须传入事件展示时区,必要时显示时区名
number.decimal指标与数量固定产品级小数精度规则
number.percent转化率、折扣率明确输入是 0.15 还是 15
money.price商品售价货币代码来自业务数据,不从 locale 推断
money.accounting财务报表负数和舍入规则需单独定义
unit.compact数据面板指定单位与紧凑显示策略

六、时间语义比日期格式更重要

日期问题往往不是格式化 API 的问题,而是数据语义没有先定义。

瞬时事件:传输一个确定时刻

订单创建时间、支付完成时间、会议开始时间属于真实世界中的同一瞬间。建议使用 UTC 或带偏移量的 ISO 8601 时间传输,例如:

2026-08-13T14:30:00Z
2026-08-13T22:30:00+08:00

展示时再根据业务规则指定时区:

  • 面向用户的操作记录:可按用户时区;
  • 门店预约:通常按门店所在地时区;
  • 全球线上活动:应显示活动定义时区,或同时显示用户本地时间与活动时区。

纯日期:不要先变成 Date

生日、账期日、门店营业日、“2026 年 8 月的报表周期”等属于无时区日期。如果后端传来 2026-08-13,前端将其解析成 JavaScript Date 后再按本地时区格式化,可能在负时区环境中显示成前一天。

这类字段应以 YYYY-MM-DD 或专门的 Plain Date 类型在业务层传递,并以“日期本身”格式化,不做时区换算。

Intl.DateTimeFormat 若不显式指定 locale 和时区,会依赖运行环境默认值;同一 UTC 时间在不同默认时区甚至可能落到不同日历日。这也是 SSR 和客户端必须统一格式上下文的原因。

七、交付、缓存与发布:语言包也是版本化资源

语言包可随前端构建产物发布,也可由 CDN 提供静态 JSON;接入翻译管理平台时,则通常需要同步、审核和发布环节。无论来源如何,都应具备版本策略。

建议资源 URL 带构建版本或内容哈希:

/locales/v2026.08.13/zh-CN/checkout.json
/locales/zh-CN/checkout.a1b2c3d4.json

这样可以避免新代码引用新键、CDN 却仍返回旧语言包的短暂不一致。发布策略上还应支持:

  • 新旧资源短期共存;
  • 出现翻译事故时回滚;
  • 前端与资源版本关联上报;
  • 缓存命中与加载失败可追踪。

对于高概率语言或下一跳路由,可在浏览器空闲时预加载;但不要无差别预取所有 locale,否则只是在后台重新制造首屏资源膨胀。

八、测试与可观测性:把国际化变成可验证系统

测试清单

  • 格式化单测:覆盖关键 locale、货币和时区;
  • 纯日期测试:验证 YYYY-MM-DD 不会因运行时区变化而偏移;
  • 翻译资源校验:键集合、插值变量、复数/select 分支、非法消息语法;
  • 动态加载测试:路由进入、语言切换、重复请求去重、失败重试和竞态保护;
  • SSR/CSR 一致性测试:以固定 locale、时区和首屏资源进行 hydration 验证;
  • 视觉测试:覆盖长文本语言、CJK、可能的 RTL 页面,以及金额和日期排版。

建议监控的指标

locale + namespace + 应用版本 分组记录:

  • 语言包压缩后体积;
  • 语言包请求与解析耗时;
  • 内存、HTTP 与 CDN 缓存命中率;
  • 资源加载失败率;
  • 语言切换完成时间;
  • 缺失翻译键率、缺失插值率;
  • 格式化异常率;
  • SSR hydration 不一致告警数。

这些指标能把“某些海外用户偶尔看到英文”从难以复现的反馈,变成可定位的资源、版本或回退链问题。

结语:国际化的核心是边界一致

可维护的前端国际化体系,不是把更多 JSON 文件塞进工程,而是建立几条稳定边界:

  1. locale × namespace 管理文案资源,并按路由和业务域加载;
  2. 将异步加载、切换提交、竞态取消和失败回退视为状态机;
  3. 让 SSR 与客户端共享 locale、首屏资源和时区策略;
  4. 将日期、数字、货币和单位收敛为基于 Intl 的产品级格式化 API;
  5. 用提取、校验、监控和版本化发布,把翻译质量纳入工程质量体系。

当这些边界明确后,新增一种语言、一个市场、一个大页面,才不会演变为首屏体积、格式规则和翻译质量的连锁失控。

参考资料