一、从一次“翻译翻车”说起
去年我们团队把一个 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 引擎够快,页面不复杂时基本无感知。
七、注意事项汇总
- 统一语言代码格式:所有端必须统一成
zh-CN、en-US这种带连字符的标准格式,并在获得原始值后立刻规范化。 - 提前设置好
fallbackLng:最好设置为最稳妥的默认语言,避免出现空白文本。 - 开启
nonExplicitSupportedLngs:防止系统语言变成en时匹配不到翻译。 - 不要在使用
t()时拼接 HTML:小程序端无法安全渲染 HTML,即使 H5 端也要通过trans组件或 dangerouslySetInnerHTML 来谨慎处理,避免 XSS 风险。 - 谨慎使用命名空间:除非翻译文件很大需要按页面拆包,否则建议只用一个命名空间,让代码更简单。
- 为用户手动选择设置持久化:存储到
Taro.setStorageSync,避免每次启动都重置。 - 做好 key 的命名规范:建议按照页面名.模块名.具体含义来命名,例如
cart.addSuccess,避免维护时找不到位置。 - 测试要覆盖两端:在微信开发者工具和真机上分别测试语言切换,因为模拟器与真机的
language格式可能不同。 - 注意翻译文本内部的换行和空白字符:JSON 里换行会被当作字符串的一部分,容易造成样式错乱,建议使用
\n代替真实换行。 - 不要用
t()来处理长文本模板:比如用户协议、隐私政策这种大段内容,更适合直接放在单独的文本文件里,按语言加载,避免 JSON 文件过于庞大。
八、文章总结
跨端国际化本质上是“一次翻译,多端复用”,但真正的难点不在于翻译机制,而在于不同平台对语言环境的细节差异处理。Taro 项目里使用 i18next + react-i18next 是一个稳健的组合,它提供了一层统一的抽象,让业务开发者不必关心每个平台的 API。与此同时,我们必须正视小程序原生组件的局限性,以及日期、货币等文化差异问题,通过合理的封装和工具函数来承载这些额外逻辑。
从工程化角度,翻译 key 的对齐检查、语言持久化、系统语言变化的监听,都是值得投入时间的环节。这些细节看似琐碎,却决定了一个国际化产品的完成度。希望这篇博客里的示例代码和踩坑记录,能让你在做多语言适配时少走弯路,把更多精力放在真正有趣的业务逻辑上。
评论
围绕“Taro跨端项目中的国际化实现及不同平台多语言适配的注意事项”参与讨论