前端国际化实战中i18n多语言切换与动态加载方案

毓珂 Dev 优化 阅读 2,122
赞 20 收藏
二维码
手机扫码查看
反馈

我的写法,亲测靠谱

国际化(i18n)这事,我前后搞过四五个项目,从 Vue 2 的 vue-i18n@8.x 到 Vue 3 的 @intlify/core,再到 React 的 react-intl 和 i18next,踩的坑基本能凑一桌麻将。最后发现:最稳、最省心、最容易维护的方案,反而是「不追求花哨,但把基础打牢」。

前端国际化实战中i18n多语言切换与动态加载方案

我现在默认用 i18next + i18next-browser-languagedetector + i18next-http-backend,不接任何框架插件(比如 react-i18next),自己封装一层 hooks 或 Composable —— 这样控制权在手,出问题能秒定位,不会被框架抽象层绕晕。

核心配置就这几行,放在 src/i18n/index.ts

import i18n from 'i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import HttpBackend from 'i18next-http-backend';

i18n
  .use(HttpBackend)
  .use(LanguageDetector)
  .init({
    fallbackLng: 'en',
    debug: import.meta.env.DEV,
    interpolation: {
      escapeValue: false,
    },
    backend: {
      loadPath: '/locales/{{lng}}/{{ns}}.json',
      // 注意:这里不是绝对路径,是相对 public/ 的路径
      // 所以我习惯把 locales 放在 public/locales/zh-CN/common.json
    },
    detection: {
      order: ['cookie', 'localStorage', 'navigator'],
      caches: ['cookie', 'localStorage'],
      lookupCookie: 'i18next',
      lookupLocalStorage: 'i18nextLng',
    },
  });

export default i18n;

为什么这么写?因为:加载路径必须可控,不能依赖构建时的静态分析。之前用过 webpack 插件自动提取 JSON,结果打包后路径错乱、404 一堆,折腾半天发现还不如老老实实放 public/ 下手动管理。

另外注意:escapeValue: false 是刚需。我们很多文案带 HTML 标签(比如“请点击这里确认”),如果开默认转义,就得满世界写 t('key', { interpolation: { escapeValue: false } }),太反人类。关掉它,统一由业务层决定是否信任内容 —— 我们自己的文案当然信得过。

这几种错误写法,别再踩坑了

下面这些,都是我在 Code Review 里反复看到、自己也干过的蠢事:

  • 把语言切换逻辑写死在组件里:比如在 Header 里写个按钮,点一下就 i18n.changeLanguage('zh'),但没同步更新 cookie / localStorage,也没触发页面重渲染(尤其 SSR 场景下直接白屏)。结果用户刷新页面又回 English —— 我第一次上线就被 PM 抓着问“你们的中文哪去了?”
  • t('user.name') 这种嵌套 key:看着像结构化,实际害人。JSON 文件里变成 {"user": {"name": "用户名"}},但翻译平台(如 Lokalise、Crowdin)根本不认这种嵌套,导出导入全乱;而且前端改个 key 得翻三四个层级,命名冲突率飙升。现在我强制扁平:"user_name": "用户名",加下划线,一眼可读,机器好解析,翻译平台友好。
  • 在 t() 里拼字符串:比如 t('hello') + name + t('welcome')。这是大忌。不同语言语序不同,中文是“欢迎 XXX”,德语可能是“XXX,Willkommen!”—— 拼接等于放弃本地化。正确写法是:t('welcome_message', { name }),JSON 里存:"welcome_message": "欢迎 {{name}}""welcome_message": "{{name}},Willkommen!"
  • 把日期/数字格式硬编码:比如 new Date().toLocaleDateString('en-US') 写死 en-US。千万别!应该用 Intl.DateTimeFormat(i18n.language)formatDate(date, { locale: i18n.language })。我有一次在德国站看到日期显示成 “Jan 1, 2024”(美式),客户直接发邮件说“你们不懂德国人怎么读日期?”。

实际项目中的坑

最烦的是服务端渲染(SSR)和客户端语言不一致。比如用户访问时服务端按浏览器 header 返回 zh-CN,但客户端初始化时检测到 localStorage 是 en,立刻切语言 —— 页面闪一下,文案来回跳。解决办法很土但有效:服务端渲染前,先读 cookie 或 header,把 i18nextLng 同步进初始 state,客户端 init 时传进去:

// SSR 端(例如 SvelteKit load 函数或 Next.js getServerSideProps)
export async function load({ url, cookies }) {
  const lng = cookies.get('i18next') || 'en';
  return { props: { lng } };
}

// 客户端 init 时
i18n.init({
  lng: $props.lng || 'en',
  // ...
});

另一个真实问题是翻译缺失兜底策略太粗暴。默认 i18next 会返回 key 名本身(比如显示 user_name),测试环境没问题,上线后运营说“用户看到英文 key 以为是 bug”。我的补救方案是在开发环境保留 key,在生产环境 fallback 到英文:

fallbackLng: {
  default: ['en'],
},
// 并加个 missing key 处理
missingKeyHandler: (lngs, ns, key) => {
  if (import.meta.env.PROD && lngs[0] !== 'en') {
    return i18n.getResource('en', ns, key) || key;
  }
},

还有个小细节:字体。中日韩文字和拉丁字母混排时,经常出现字重/行高不一致。我们最后统一用 CSS 控制:

html[lang^="zh"],
html[lang^="ja"],
html[lang^="ko"] {
  font-family: "PingFang SC", "Hiragino Sans GB", "Noto Sans CJK SC", system-ui;
}

不然设计师天天截图问:“为什么中文看起来矮一截?”

结尾

以上是我总结的最佳实践,没有银弹,只有一次次线上翻车后的妥协与优化。比如现在这套方案,还是没法完美支持 RTL(阿拉伯语镜像布局),我们暂时靠 CSS logical properties + direction: rtl 临时顶着,等哪天真要上阿语站,再啃 RTL 的坑。

这个方案不是最炫的,但够稳、够透明、够容易查问题。如果你有更好的动态 namespace 加载方式、或者更轻量的检测逻辑,欢迎评论区交流 —— 我已经准备好重启编辑器了。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论