广告位 · 页头(后台-模块管理-新建模块后替换此区域)

JavaScript 项目如何实现完整的多语言国际化支持?

前端国际化的工作经常被简化为替换字符串,但实际落地会涉及三个层次:文本资源管理、运行时格式化以及构建与加载策略。如果只处理文本替换,很快就会遇到复数规则导致翻译键爆炸、日期显示格式不符合当地习惯、语言包体积失控等问题。理解这些层次之间的联系,才能设计出可维护的多语言方案。

JavaScript 项目如何实现完整的多语言国际化支持?

先把文本从业务代码中抽离出来

国际化改造的第一步不是引入库,而是把散落在组件、模板和工具函数里的用户可见字符串集中到翻译资源文件中。直接写中文或英文文案会让后续替换变成全文搜索的体力活,而且无法区分相同文案在不同上下文中的含义。例如按钮上的“提交”和提示语中的“提交成功”虽然包含相同的词,但翻译键必须独立,否则英文环境下无法分别翻译成 Submit 和 Saved successfully。

推荐的翻译键命名采用层级结构,例如 button.submit、message.saveSuccess。这种方式与 JSON 文件的结构天然对应,也方便按命名空间拆分。翻译资源文件可以放在 locales 目录下,例如 zh-CN 和 en-US 两个文件夹各自维护一份 JSON。

{
  "button": {
    "submit": "提交",
    "cancel": "取消"
  },
  "message": {
    "saveSuccess": "保存成功",
    "deleteConfirm": "确定要删除吗?"
  }
}

在代码中通过翻译函数获取文本,而不是直接拼接字符串。下面用 i18next 作为示例,最简单的调用方式是 i18next.t('button.submit')。这样业务组件与语言资源解耦,新增语言时只需要补充对应的 JSON 文件,不需要改动组件逻辑。

import i18next from 'i18next';
i18next.init({
  resources: {
    'zh-CN': {
      translation: {
        button: { submit: '提交' },
        message: { saveSuccess: '保存成功' }
      }
    },
    'en-US': {
      translation: {
        button: { submit: 'Submit' },
        message: { saveSuccess: 'Saved successfully' }
      }
    }
  },
  lng: 'zh-CN',
  fallbackLng: 'en-US'
});
console.log(i18next.t('button.submit'));

随着项目变大,单个 translation 文件会膨胀到上千行。此时可以启用命名空间,例如 common、auth、dashboard 分别存放不同模块的文案。i18next 支持通过 ns 配置默认命名空间,也可以在调用时使用 t('auth:login.title') 的形式指定,这样语言包可以按模块拆分并按需加载。

原生 Intl API 能帮你处理格式,但不能替代翻译管理

JavaScript 内置的 Intl 对象提供了强大的格式化能力,包括数字、货币、日期、相对时间以及复数规则。它的优势是直接运行在浏览器或 Node.js 中,不需要额外引入依赖,而且格式结果严格遵循各个地区的 CLDR 数据。例如同一个数字在德国和美国的显示方式完全不同,使用 Intl.NumberFormat 可以避免手动拼接货币符号和小数点。

const price = 123456.789;
const germanPrice = new Intl.NumberFormat('de-DE', {
  style: 'currency',
  currency: 'EUR'
}).format(price);
const americanPrice = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
}).format(price);
console.log(germanPrice);  // 123.456,79 €
console.log(americanPrice); // $123,456.79

日期格式化同样如此。Intl.DateTimeFormat 提供的 dateStyle 和 timeStyle 选项可以生成符合当地习惯的长短格式,不需要自己写 YYYY-MM-DD 之类的拼接逻辑。在处理用户输入或展示时间时,应该优先使用这个 API,而不是固定格式。

const now = new Date();
const usDate = new Intl.DateTimeFormat('en-US', {
  dateStyle: 'full',
  timeStyle: 'short'
}).format(now);
const zhDate = new Intl.DateTimeFormat('zh-CN', {
  dateStyle: 'long',
  timeStyle: 'short'
}).format(now);
console.log(usDate);
console.log(zhDate);

但是 Intl 并不负责翻译文本的存储和查找,也没有提供语言检测、回退链管理或异步加载能力。它的定位是格式化工具,而不是国际化框架。真正的多语言方案通常是把 Intl 作为底层,与 i18next 这类库配合使用。例如在 i18next 的插值函数中手动调用 Intl.NumberFormat,或者直接在组件里根据当前语言创建格式化器。

此外,Intl.PluralRules 可以帮助确定某个数字应该使用哪种复数形式。不同语言的复数规则差异很大,中文只有一种,英语有单数和复数两种,阿拉伯语则有六种。使用这个 API 可以避免在代码里硬编码 count === 1 的判断。

const pr = new Intl.PluralRules('en-US');
console.log(pr.select(0)); // other
console.log(pr.select(1)); // one
console.log(pr.select(2)); // other
console.log(pr.select(5)); // other

用 i18next 搭建完整的翻译体系

i18next 是目前使用最广泛的 JavaScript 国际化框架,它把翻译资源、语言检测、插值、复数、回退和插件系统整合在一起。初始配置中,resources 可以直接挂载同步资源,但在真实项目中,语言包通常放在独立的 JSON 文件中,通过 i18next-http-backend 异步加载。

插值是翻译中常见的需求。比如欢迎语“你好,{{name}}”,翻译文件里使用 {{name}} 占位,运行时通过 t('welcome', { name: '张三' }) 把参数传入。这样做可以保持翻译资源的完整性,避免字符串拼接破坏语序。

import i18next from 'i18next';
import HttpBackend from 'i18next-http-backend';
i18next.use(HttpBackend).init({
  lng: 'zh-CN',
  fallbackLng: 'en-US',
  ns: ['common', 'auth'],
  defaultNS: 'common',
  backend: {
    loadPath: '/locales/{{lng}}/{{ns}}.json'
  },
  interpolation: {
    escapeValue: false
  }
});
const userName = '小明';
const welcome = i18next.t('welcome', { name: userName });
console.log(welcome); // 你好,小明

上面的配置里,loadPath 使用 {{lng}} 和 {{ns}} 占位,实际请求时会替换成 /locales/zh-CN/common.json。这要求语言包文件按语言和命名空间组织在服务器或静态目录中。escapeValue 设置为 false 是因为 i18next 默认会对插值结果做 HTML 转义,在 React 或 Vue 中由框架负责转义,重复转义反而会导致显示异常。

复数处理在 i18next 中通过键后缀实现。英语环境下 item 键需要提供 item_one 和 item_other,i18next 会根据传入的 count 自动选择。中文虽然复数形式简单,但为了兼容其他语言,建议从一开始就按复数规则维护翻译文件。

{
  "item": "你有 {{count}} 个项目",
  "item_one": "你有 {{count}} 个项目",
  "item_other": "你有 {{count}} 个项目"
}

调用时传入 count 即可:i18next.t('item', { count: 3 })。i18next 内部会根据当前语言的复数规则查找对应后缀,如果找不到则回退到 item 或 item_other。理解这个回退顺序对于排查翻译不生效的问题很有帮助。

语言检测、持久化和按需加载怎么做

语言检测的目标是尽可能猜测用户的首选语言,同时尊重用户的手动选择。常见做法是先读取 localStorage 中保存的语言,其次读取浏览器 navigator.language,最后回退到默认语言。i18next 的 languageDetector 插件封装了这一流程,支持自定义检测顺序。

const languageDetector = {
  type: 'languageDetector',
  detect: function() {
    const saved = window.localStorage.getItem('app-language');
    if (saved) {
      return saved;
    }
    const browserLang = navigator.language || navigator.userLanguage;
    return browserLang.split('-')[0];
  },
  cacheUserLanguage: function(lng) {
    window.localStorage.setItem('app-language', lng);
  }
};
i18next.use(languageDetector).init({
  fallbackLng: 'en-US',
  detection: {
    order: ['localStorage', 'navigator', 'htmlTag']
  }
});

手动切换语言后,除了调用 i18next.changeLanguage(lng),还应该把结果写入 localStorage。上面的检测器已经通过 cacheUserLanguage 在语言切换时自动保存。这样用户刷新页面后不会回到默认语言。需要注意的是,如果语言包是异步加载的,切换语言时应处理加载状态,避免界面短暂显示翻译键。

按需加载是控制首屏体积的关键。把所有语言的所有模块打包进主 bundle 会让用户下载大量用不到的资源。借助模块化构建工具,可以按路由或按语言拆分翻译文件。i18next 的 i18next-http-backend 天然支持按命名空间请求,而 Webpack 或 Vite 的动态 import 可以更进一步将语言包拆成独立 chunk。例如:

async function loadMessages(lng) {
  const module = await import(`./locales/${lng}/common.json`);
  i18next.addResourceBundle(lng, 'common', module.default, true, true);
}

这段代码在需要某个语言时再加载对应的 JSON,比一开始全量请求更轻便。配合命名空间,dashboard 模块的文案只有在用户进入仪表盘时才请求,登录页只需要加载 auth 和 common 两个命名空间。这样即使语言包总量很大,也不会影响首屏速度。

按需加载还需要处理缓存问题。如果 JSON 文件带 hash 后缀,更新翻译后 URL 会变化,天然绕过浏览器缓存;如果不带 hash,则需要在服务器配置短缓存时间或使用协商缓存,避免用户长期看到旧文案。

复数、回退链和上下文中的常见误区

复数规则是国际化中最容易出错的地方。开发者常写的 if (count === 1) return 'item'; else return 'items'; 只适用于英语,在俄语、阿拉伯语等语言中会得到错误的语法形式。i18next 和 Intl.PluralRules 都提供了标准化的复数类别,包括 zero、one、two、few、many、other。翻译文件应该按这些类别提供后缀,而不是自己发明规则。

回退链的作用是当某个语言缺少某个翻译键时,自动尝试其他语言。例如用户选择繁体中文 zh-TW,但项目只有简体中文 zh-CN 和英文 en-US,可以配置 fallbackLng: ['zh-CN', 'en-US']。i18next 会先找 zh-TW 的翻译,找不到就依次回退到 zh-CN 和 en-US。注意回退链的顺序会影响最终显示,应该把语言相似度高的放在前面。

上下文翻译可以解决同一个英文单词在不同场景下翻译不同的问题。比如英文 friend 在“好友列表”和“添加好友”中都是 friend,但中文可能需要翻译成“好友”和“朋友”。i18next 支持 context 选项,翻译文件使用下划线后缀区分:

{
  "friend": "朋友",
  "friend_male": "男性朋友",
  "friend_female": "女性朋友",
  "friend_list": "好友"
}

调用时通过 i18next.t('friend', { context: 'list' }) 得到“好友”,没有匹配到上下文时回退到普通键。上下文与复数可以组合使用,例如 item_male_one,但这类组合键不宜过多,否则翻译文件会变得难以维护。通常只在语义确实依赖场景时才使用上下文。

另一个常见误区是硬编码日期和数字格式。即使翻译文本全部正确,如果日期仍然显示为 2025/01/01,德语用户仍然会觉得不够本地化。应统一使用 Intl.DateTimeFormat 或 i18next 的格式化接口,不要在组件里写死 toLocaleDateString 的参数,因为不同用户的语言可能不同。

最后,国际化不仅包括前端显示,还包括排序、搜索、大小写转换等行为。不同语言的排序规则差异巨大,Intl.Collator 可以处理当地化的字符串比较。虽然这部分不属于文案翻译,但对完整的多语言体验同样重要。设计国际化方案时,可以把 Intl 系列 API 视为格式化基础设施,i18next 视为资源管理层,二者各司其职。

广告位 · 文章底部(后台-模块管理替换此区域)