一、从一次“翻译翻车”说起

去年我们团队把一个 Taro 电商项目从只支持中文改为支持中英文,结果上线第二天,日本用户反馈购物车页面出现乱码。排查了半天,发现是某个第三方组件库里的字符串没走国际化,直接写死了中文。更尴尬的是,安卓端能正常切换,iOS 端却一直显示英文——后来才搞明白是本地存储的键名在两端不一致。那次之后,我花了整整一周把整个项目的国际化方案重新梳理了一遍。今天就把这些经验掰开揉碎了讲给你听。

二、Taro 国际化到底在解决什么问题

Taro 是一套基于 React 语法、可以一套代码编译到微信小程序、H5、React Native 等多端的框架。跨端项目里做国际化,最大的难点不在于“怎么写翻译文件”,而在于“不同平台对语言环境的感知方式不同”。

比如小程序里,语言环境是跟随微信客户端设置的,你要读取当前语言,需要用 Taro.getSystemInfoSync() 拿到 language 字段。H5 端则需要用浏览器的 navigator.language。React Native 端又得依赖原生模块。如果每个端都写一套判断逻辑,代码会乱成一锅粥。

所以,我们需要的是一套统一的抽象层:不管运行在哪个平台,业务代码里都只调用同一个方法去获取翻译文本,至于底层怎么适配,由这个抽象层自己搞定。

三、技术选型:Taro + i18next

目前社区里比较成熟的方案是 i18next 配合 react-i18next。i18next 本身和框架无关,支持浏览器、Node.js、原生环境,正好可以塞进 Taro 的多端编译体系里。我们只需要在入口文件里初始化 i18next,再封装一个 Taro 专用的语言检测函数就行。

为什么不选 react-intl?因为它的 ICU MessageFormat 语法在某些小程序环境里会有兼容性问题,而且 react-intl 的 API 偏重,对 Taro 这种需要打包体积控制的项目不太友好。i18next 的 JSON 翻译文件格式简单直观,插件生态也丰富,比如支持懒加载、命名空间、复数规则,非常适合跨端场景。

四、实战:一个完整的 Taro 国际化示例

技术栈:Taro 3.x + React 18 + TypeScript + i18next + react-i18next

4.1 安装依赖


# 在项目根目录执行
npm install i18next react-i18next

4.2 创建翻译文件

我们在 src/locales 下建两个 JSON 文件,分别对应中文和英文。

// src/locales/zh-CN.json
{
  "common": {
    "appName": "购物助手",
    "confirm": "确认",
    "cancel": "取消"
  },
  "cart": {
    "title": "购物车",
    "total": "合计:{{amount}} 元",
    "empty": "您的购物车是空的",
    "addSuccess": "已加入购物车"
  }
}

// src/locales/en-US.json
{
  "common": {
    "appName": "Shopping Assistant",
    "confirm": "OK",
    "cancel": "Cancel"
  },
  "cart": {
    "title": "Cart",
    "total": "Total: ${{amount}}",
    "empty": "Your cart is empty",
    "addSuccess": "Added to cart"
  }
}

注意看,cart.total 使用了占位符 {{amount}},我们在代码里传入不同的数字或货币符号,就能实现动态文本替换。这在小程序里尤其重要,因为不能直接操作 DOM 字符串,只能通过数据绑定。

4.3 语言检测工具函数

不同平台获取当前语言的代码差异很大,所以我们要写一个统一的检测函数。

// src/utils/language.ts
// 技术栈:Taro + TypeScript
import Taro from '@tarojs/taro'

/**
 * 获取当前平台的语言代码
 * 小程序:从系统信息里读取 language 字段
 * H5:从浏览器 navigator 对象读取
 * React Native:Taro.getSystemInfoSync 同样适用
 */
export function getSystemLanguage(): string {
  // 使用 try/catch 防止某些平台不支持相关 API 导致报错
  try {
    const info = Taro.getSystemInfoSync()
    // 小程序和 RN 都支持这个字段,值类似 'zh-CN'、'en-US'
    if (info.language) {
      return info.language
    }
  } catch (e) {
    // 忽略错误,继续走浏览器逻辑
  }

  // H5 环境
  if (typeof navigator !== 'undefined' && navigator.language) {
    return navigator.language
  }

  // 兜底默认值
  return 'zh-CN'
}

4.4 初始化 i18next

在 src/app.ts 里初始化,这一步非常关键,因为所有页面都要依赖这个实例。

// src/app.ts
// 技术栈:Taro + React + i18next
import { Component } from 'react'
import Taro from '@tarojs/taro'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import { getSystemLanguage } from './utils/language'
import zhCN from './locales/zh-CN.json'
import enUS from './locales/en-US.json'

// 初始化 i18next
i18n.use(initReactI18next).init({
  // 指定加载的翻译资源,实际项目中可改为异步加载
  resources: {
    'zh-CN': { translation: zhCN },
    'en-US': { translation: enUS }
  },
  // 默认语言:调用我们封装好的检测函数
  lng: getSystemLanguage(),
  // 如果没有匹配到其他语言,就使用中文作为回退
  fallbackLng: 'zh-CN',
  // 让代码更干净,控制台不会输出大量调试信息
  debug: false,
  // 对 JSON 里的特殊字符做转义,防止 XSS 攻击
  interpolation: {
    escapeValue: false
  }
})

class App extends Component {
  componentDidShow() {
    // 当小程序从后台切回前台时,语言环境可能发生变化(比如用户切系统语言)
    // 这里重新检测并更新
    const currentLng = i18n.language
    const systemLng = getSystemLanguage()
    if (currentLng !== systemLng) {
      i18n.changeLanguage(systemLng)
    }
  }

  render() {
    // 这是 Taro 脚手架的默认写法
    return this.props.children
  }
}

export default App

4.5 在页面组件里使用翻译

写一个购物车页面,展示如何读取翻译文本、动态替换占位符,以及处理语言切换。

// src/pages/cart/index.tsx
// 技术栈:Taro + React + react-i18next
import { View, Text, Button } from '@tarojs/components'
import { useTranslation } from 'react-i18next'
import Taro from '@tarojs/taro'
import './index.scss'

export default function CartPage() {
  // useTranslation 是 react-i18next 提供的 Hook
  // 它会返回 t 函数和 i18n 实例
  const { t, i18n } = useTranslation()

  // 模拟购物车金额
  const totalAmount = 199.5

  // 语言切换处理函数
  const handleSwitchLanguage = () => {
    const nextLang = i18n.language === 'zh-CN' ? 'en-US' : 'zh-CN'
    i18n.changeLanguage(nextLang)
    // 可选:将用户选择持久化到本地,避免每次进入 App 都跟随系统
    Taro.setStorageSync('user_language', nextLang)
  }

  return (
    <View className='cart-container'>
      {/* 普通文本翻译 */}
      <Text className='cart-title'>{t('cart.title')}</Text>

      {/* 带占位符的翻译文本,传入动态参数 { amount } */}
      <Text className='cart-total'>{t('cart.total', { amount: totalAmount })}</Text>

      {/* 条件渲染:翻译内容也可以用在表达式里 */}
      {totalAmount === 0 ? (
        <Text>{t('cart.empty')}</Text>
      ) : (
        <Button onClick={handleSwitchLanguage}>
          {i18n.language === 'zh-CN' ? 'Switch to English' : '切换到中文'}
        </Button>
      )}
    </View>
  )
}

这个例子里我们用了 useTranslation Hook,它的好处是当 changeLanguage 被调用时,组件会自动重新渲染,所有带 t() 的文本都会瞬间更新。这一点在小程序端表现得很棒,因为 Taro 会把翻译后的字符串作为数据传到视图层,不需要手动 setState。

4.6 处理带复数的翻译

不同语言的复数规则差异巨大,比如英文有单复数,中文没有语法上的复数。i18next 内置了 _plural 后缀机制。

// src/locales/en-US.json 里追加
{
  "cart": {
    "itemCount": "{{count}} item",
    "itemCount_plural": "{{count}} items"
  }
}

// src/locales/zh-CN.json 里追加
{
  "cart": {
    "itemCount": "共 {{count}} 件商品",
    "itemCount_plural": "共 {{count}} 件商品"
  }
}

调用方式:

// 代码里这样写,i18next 会自动根据 count 选择单复数版本
// 技术栈:JavaScript + i18next
const text = t('cart.itemCount', { count: 3 })
console.log(text) // 英文环境下会输出 "3 items"

注意,中文环境下无论 count 是多少,都使用同一个翻译,所以我们在 JSON 里把复数版本也写成一样的内容,避免 i18next 因找不到 _plural 报错。

4.7 语言切换时持久化用户偏好

系统语言不一定符合用户偏好,所以我们要优先读取用户之前手动选择的语言,其次再读取系统语言。

// src/app.ts 里修改初始化逻辑
// 技术栈:Taro + i18next
import Taro from '@tarojs/taro'
import { getSystemLanguage } from './utils/language'

function getInitialLanguage(): string {
  // 先尝试读取用户手动设置过的语言
  const savedLang = Taro.getStorageSync('user_language')
  if (savedLang === 'zh-CN' || savedLang === 'en-US') {
    return savedLang
  }
  // 如果没有手动设置,用系统语言
  return getSystemLanguage()
}

// 初始化时使用 getInitialLanguage() 代替 getSystemLanguage()

这样改完之后,即使用户的手机系统是英文,只要他曾经在 App 里手动切到中文,下次进入还是中文。这个细节很多项目都会忽略,但它直接决定用户体验是否自然。

五、不同平台多语言适配的注意事项

5.1 微信小程序的特殊坑

微信小程序的 Taro.getSystemInfoSync() 返回的 language 字段值是类似 "zh_CN" 这种下划线格式,而不是标准化的 "zh-CN"。我们需要做一次格式转换。

// 修正语言代码格式
function normalizeLanguage(rawLanguage: string): string {
  // 将 'zh_CN' 转换为 'zh-CN'
  return rawLanguage.replace('_', '-')
}

// 在 getSystemLanguage 里调用 normalizeLanguage()

另外,微信小程序里 changeLanguage 之后,如果页面使用了 useTranslation,Taro 会重新渲染页面,但有一部分原生组件的文案(比如 Button 的 open-type 属性带来的“转发”“获取头像”等)是微信自己控制的,不受 i18next 影响。这类文案只能原样保留,或者通过隐藏原生按钮、用自定义事件来替代,这是个常见的妥协点。

5.2 H5 端要注意浏览器缓存

在 H5 端,用户刷新页面后语言设置会重新初始化。如果翻译文件是静态打包在 JS 里的,那么刷新不是问题,但如果是异步加载翻译文件,就需要考虑缓存策略。最简单的方式是把所有翻译文件提前打包进 app.json 的 resources 里,虽然会稍微增大首屏包体,但可以避免网络请求和加载时序问题。对于 H5 来说更推荐懒加载,但懒加载会导致第一次切换语言时出现短暂的空白,需要加载状态管理。

5.3 React Native 端的 language 值不稳定

在 RN 端,有的安卓设备 Taro.getSystemInfoSync().language 会返回 "en" 而不是 "en-US",如果我们只配置了 "en-US" 而没有配置 "en",i18next 会根据 fallbackLng 回退到中文。这就很糟糕,用户明明是英文系统却看到中文。解决办法是在初始化时配置 supportedLngs 和 nonExplicitSupportedLngs。

// 在 i18next 初始化配置里补充
// 技术栈:JavaScript + i18next 配置
i18n.init({
  supportedLngs: ['zh-CN', 'en-US'],
  // 允许 'en' 匹配到 'en-US'
  nonExplicitSupportedLngs: true,
  fallbackLng: 'zh-CN'
})

配置 nonExplicitSupportedLngs 之后,i18next 会自动把 "en" 当作 "en-US" 来处理。

5.4 语言切换时的事件广播

有些业务场景不依赖 React 组件,比如使用 Redux 的纯工具函数、网络请求封装层,它们也要输出用户可读的错误信息。这时候我们不能在每个函数里都调用 i18n.t(),因为 i18next 实例不是响应式数据。更优雅的做法是在语言切换时触发一个全局事件,让那些非组件模块重新获取翻译。

// src/utils/i18nEvents.ts
// 技术栈:TypeScript + Taro 事件系统
import Taro from '@tarojs/taro'

// 封装一个简单的事件订阅发布器
const languageChangeListeners = new Set<(lang: string) => void>()

export function onLanguageChange(callback: (lang: string) => void) {
  languageChangeListeners.add(callback)
}

export function emitLanguageChange(lang: string) {
  languageChangeListeners.forEach((cb) => cb(lang))
}

// 在切换语言的公共方法里统一调用 emitLanguageChange

5.5 日期和货币的格式化

翻译不只是字符串替换。不同地区的日期格式、货币符号、千分位分隔符差异很大。i18next 本身不处理这些,但我们可以配合 Intl 对象来实现。不过要注意,iOS 的 JavaScriptCore 引擎对 Intl 支持不完整,在小程序里使用 Intl.NumberFormat 可能在某些低版本安卓上崩溃。稳妥的方案是自己写一个简单的格式化函数。

// src/utils/format.ts
// 技术栈:TypeScript
// 根据语言代码返回货币前缀
export function getCurrencyPrefix(lang: string): string {
  if (lang.startsWith('zh')) {
    return '¥'
  }
  return '$'
}

// 简单的千分位格式化,不依赖 Intl 对象
export function formatNumberWithThousands(num: number): string {
  return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
}

// 组合使用:输出 "¥1,299.00"
export function formatPrice(amount: number, lang: string): string {
  const prefix = getCurrencyPrefix(lang)
  const formatted = formatNumberWithThousands(amount.toFixed(2))
  return `${prefix}${formatted}`
}

然后在翻译文本里不要写死货币符号,而是通过占位符传入:

// 修改 en-US.json
{
  "cart": {
    "total": "Total: {{price}}"
  }
}
// 修改 zh-CN.json
{
  "cart": {
    "total": "合计:{{price}}"
  }
}

组件里这样调用:

// 技术栈:TypeScript + react-i18next
const lang = i18n.language
const price = formatPrice(totalAmount, lang)
<Text>{t('cart.total', { price })}</Text>

这样能保证翻译文本里只有语言本身的内容,不带任何业务格式,以后要加欧元、日元也方便。

5.6 翻译文件的管理

翻译文件多了之后,手工维护很容易漏掉某个键。强烈建议开发时开启 i18next 的 debug: true,它会在控制台里警告缺失的翻译键。同时,在 CI/CD 流程里加一个脚本,自动比对 zh-CN.json 和 en-US.json 的 key 集合是否一致。这个脚本可以用简单 Node.js 实现。

// scripts/checkTranslationKeys.js
// 技术栈:Node.js
const fs = require('fs')
const path = require('path')

// 读取两个语言文件
const zh = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../src/locales/zh-CN.json'), 'utf8'))
const en = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../src/locales/en-US.json'), 'utf8'))

// 递归提取所有的 key 路径,比如 'cart.title'
function flatKeys(obj, prefix = '') {
  let keys = []
  for (const key of Object.keys(obj)) {
    const fullKey = prefix ? `${prefix}.${key}` : key
    if (typeof obj[key] === 'object') {
      keys = keys.concat(flatKeys(obj[key], fullKey))
    } else {
      keys.push(fullKey)
    }
  }
  return keys
}

const zhKeys = flatKeys(zh)
const enKeys = flatKeys(en)

// 找出差异
const missingInEn = zhKeys.filter((k) => !enKeys.includes(k))
const missingInZh = enKeys.filter((k) => !zhKeys.includes(k))

if (missingInEn.length > 0) {
  console.error('这些 key 在英文文件中缺失:', missingInEn)
}
if (missingInZh.length > 0) {
  console.error('这些 key 在中文文件中缺失:', missingInZh)
}
if (missingInEn.length > 0 || missingInZh.length > 0) {
  process.exit(1)
}
console.log('所有翻译 key 都对齐了,很棒!')

把这个脚本挂到 package.json 里:

{
  "scripts": {
    "check:i18n": "node scripts/checkTranslationKeys.js"
  }
}

每次提交代码前跑一遍,漏翻译的情况就会大幅减少。

六、技术优缺点分析

6.1 这个方案的优势

  • 跨端一致性好:业务代码只跟 i18next 打交道,不关心底层平台,切换平台时不用改翻译逻辑。
  • 社区生态成熟:i18next 有大量插件,支持懒加载、缓存、检测浏览器语言、与服务端多语言 API 对接等。
  • 打包体积可控:核心库压缩后约 20KB,在 Taro 小程序里完全可以接受。
  • 动态语言切换体验流畅:react-i18next 的 Hook 机制保证了组件级响应,切换语言不需要刷新页面。

6.2 这个方案的缺点

  • 初始化复杂度偏高:需要处理各平台的语言检测差异,以及各种边界情况(zh_CN、en 等)。
  • 翻译文件需要额外维护:业务量大时,翻译文件会膨胀,如果没有自动化检查,漏翻问题很难避免。
  • 原生组件文案无法覆盖:比如微信小程序的原生弹窗、分享面板、按钮里的系统文案,还是只能跟随系统语言,不能跟随 App 内部语言设置。
  • 性能上有一点损耗:每次渲染调用 t() 都会做一次对象查找,好在 JavaScript 引擎够快,页面不复杂时基本无感知。

七、注意事项汇总

  1. 统一语言代码格式:所有端必须统一成 zh-CN、en-US 这种带连字符的标准格式,并在获得原始值后立刻规范化。
  2. 提前设置好 fallbackLng:最好设置为最稳妥的默认语言,避免出现空白文本。
  3. 开启 nonExplicitSupportedLngs:防止系统语言变成 en 时匹配不到翻译。
  4. 不要在使用 t() 时拼接 HTML:小程序端无法安全渲染 HTML,即使 H5 端也要通过 trans 组件或 dangerouslySetInnerHTML 来谨慎处理,避免 XSS 风险。
  5. 谨慎使用命名空间:除非翻译文件很大需要按页面拆包,否则建议只用一个命名空间,让代码更简单。
  6. 为用户手动选择设置持久化:存储到 Taro.setStorageSync,避免每次启动都重置。
  7. 做好 key 的命名规范:建议按照页面名.模块名.具体含义来命名,例如 cart.addSuccess,避免维护时找不到位置。
  8. 测试要覆盖两端:在微信开发者工具和真机上分别测试语言切换,因为模拟器与真机的 language 格式可能不同。
  9. 注意翻译文本内部的换行和空白字符:JSON 里换行会被当作字符串的一部分,容易造成样式错乱,建议使用 \n 代替真实换行。
  10. 不要用 t() 来处理长文本模板:比如用户协议、隐私政策这种大段内容,更适合直接放在单独的文本文件里,按语言加载,避免 JSON 文件过于庞大。

八、文章总结

跨端国际化本质上是“一次翻译,多端复用”,但真正的难点不在于翻译机制,而在于不同平台对语言环境的细节差异处理。Taro 项目里使用 i18next + react-i18next 是一个稳健的组合,它提供了一层统一的抽象,让业务开发者不必关心每个平台的 API。与此同时,我们必须正视小程序原生组件的局限性,以及日期、货币等文化差异问题,通过合理的封装和工具函数来承载这些额外逻辑。

从工程化角度,翻译 key 的对齐检查、语言持久化、系统语言变化的监听,都是值得投入时间的环节。这些细节看似琐碎,却决定了一个国际化产品的完成度。希望这篇博客里的示例代码和踩坑记录,能让你在做多语言适配时少走弯路,把更多精力放在真正有趣的业务逻辑上。