微信小程序 + Taro + 多语言实现与国际化检查实录
项目同时有 Web 和微信小程序,两端都需要中英文。除了把页面文案替换成 t(),还要处理语言选择的保存、校验消息、微信原生 TabBar,以及共享业务文案的归属。
我在这里把运行时和开发检查分开处理:小程序使用 i18next、react-i18next 和 Taro API;开发阶段由 i18next-cli 检查通用问题,再用少量代码补充项目特有的约束。这样不用重新引入 ESLint,也不必自己维护一整套翻译调用分析器。
解决方案
先明确依赖和文件位置
本项目使用的相关版本如下。这是项目当前采用的组合,不是其他项目必须满足的最低版本。
| 依赖 | 版本 | 作用 |
|---|---|---|
| Taro | 4.3.2 | 微信小程序运行和平台 API |
| React | 18.3.1 | 页面组件 |
| i18next | 27.0.1 | 翻译资源、插值和语言切换 |
| react-i18next | 18.0.1 | React Provider 和 useTranslation |
| i18next-cli | 1.87.0 | 开发阶段的资源和源码检查 |
| Zod | 4.6.7 | 表单校验及校验消息语言 |
i18next-cli 的运行环境要求 Node.js 22 或以上。本次接入使用 Node.js 24.15.0。仓库中的检查脚本还会调用已有的 TypeScript、esbuild 和 rg,需要先完成工作区依赖安装,并确保 rg 在 PATH 中可用。
如果从现有 Taro 项目开始接入,在仓库根目录执行:
bash
pnpm --filter miniprogram add i18next@27.0.1 react-i18next@18.0.1
pnpm add -Dw i18next-cli@1.87.0
本项目已经安装这些依赖,不需要重复添加。使用 pnpm 管理依赖和锁文件即可。
主要实现位置如下,路径均相对于仓库根目录:
text
packages/common/src/i18n-resources/
locales.ts 支持语言和默认语言
types.ts 翻译结构和 key 类型
diagnostics.ts 缺失 key 和空值插值策略
validate-key.ts 动态 key 的运行时校验
resources.ts 共享业务资源
locales/zh-CN/ 按业务领域拆分的中文资源
locales/en-US/ 对应英文资源
apps/miniprogram/src/
i18n.ts 小程序初始化、存储和切换
i18n-resources.ts 小程序专属资源组装
i18next.d.ts i18next 类型扩展
app.tsx Provider 和原生 TabBar 更新
locales/zh-CN/ 小程序专属中文资源
locales/en-US/ 小程序专属英文资源
scripts/
i18next.config.mjs 官方检查工具的配置工厂
i18n-resources.ts 导出检查所需资源并检查合并冲突
check-i18n-structure.cjs 翻译源文件的结构约束
check-i18n-project.cjs 命名空间及业务映射等项目约束
check-i18n.mjs 临时资源、CLI 和类型检查的执行入口
共享业务文案和平台文案分开
我没有把所有文案都放进小程序目录。状态、枚举标签、业务校验和业务消息由 Web、小程序共享,放在 packages/common。小程序专属的页面、扫码和导航等文案则放在应用内。
资源文件按语言、业务领域拆分,只保存字符串和嵌套对象。不要在单个语言文件中导入另一种语言,也不要用计算、函数或跨文件引用生成译文。资源组装单独放在 index.ts 等模块里。
例如,apps/miniprogram/src/locales/zh-CN/tabs.ts:
typescript
export const tabsTranslations = {
home: "主页",
devices: "设备",
map: "地图",
alarms: "告警",
profile: "我的",
} as const
对应的 apps/miniprogram/src/locales/en-US/tabs.ts:
typescript
export const tabsTranslations = {
home: "Home",
devices: "Devices",
map: "Map",
alarms: "Alarms",
profile: "Profile",
} as const
两种语言通过各自的 index.ts 组装。以中文资源为例,现有结构为:
typescript
import { tabsTranslations } from "./tabs"
import { mapTranslations } from "./map"
import { claimTranslations } from "./claim"
import { loginTranslations } from "./login"
export const miniTranslationsZhCN = {
tabs: tabsTranslations,
map: mapTranslations,
claim: claimTranslations,
login: loginTranslations,
} as const
英文组装使用相同的键,并通过 satisfies TranslationShape<typeof miniTranslationsZhCN> 约束结构。
这里的 TranslationShape 保留对象的字段结构,但允许不同语言使用不同字符串:
typescript
export type TranslationShape<T> = T extends string ? string : { readonly [K in keyof T]: TranslationShape<T[K]> }
小程序的 i18n-resources.ts 再将语言与资源关联:
typescript
import type { Locale, TranslationShape } from "@repo/common/i18n-resources"
import { miniTranslationsZhCN } from "./locales/zh-CN"
import { miniTranslationsEnUS } from "./locales/en-US"
export const miniProgramResources = {
"zh-CN": miniTranslationsZhCN,
"en-US": miniTranslationsEnUS,
} as const satisfies Record<Locale, TranslationShape<typeof miniTranslationsZhCN>>
最终运行时只使用一个 translation 命名空间。共享资源直接保留业务路径,小程序专属资源放到 mini 下,因此页面调用的是 t("mini.tabs.home")。
mini 是资源对象中的路径前缀,不是另一个 i18next 命名空间。这样页面统一使用 useTranslation(),不用在调用时反复选择命名空间。
在小程序侧初始化并保存语言
支持语言定义在 packages/common/src/i18n-resources/locales.ts,当前为 zh-CN 和 en-US,默认 zh-CN。
语言存储属于平台边界,所以这里使用 Taro.getStorageSync 和 Taro.setStorageSync,没有把存储操作放进 common,也没有引入浏览器语言检测插件或 localStorage。
apps/miniprogram/src/i18n.ts 的核心实现如下:
typescript
import { miniProgramResources } from "@/i18n-resources"
import Taro from "@tarojs/taro"
import {
createTranslationDiagnostics,
defaultLocale,
isLocale,
requireTranslationKey,
sharedResources,
type Locale,
} from "@repo/common/i18n-resources"
import { createInstance } from "i18next"
import { initReactI18next } from "react-i18next"
import { z } from "zod"
const languageKey = "language"
const storedLanguage: unknown = Taro.getStorageSync(languageKey)
const language: Locale = isLocale(storedLanguage) ? storedLanguage : defaultLocale
z.config(language === "en-US" ? z.locales.en() : z.locales.zhCN())
const resources = {
"zh-CN": {
translation: {
...sharedResources["zh-CN"],
mini: miniProgramResources["zh-CN"],
},
},
"en-US": {
translation: {
...sharedResources["en-US"],
mini: miniProgramResources["en-US"],
},
},
} as const
export const requireI18nKey = (key: string) => requireTranslationKey(resources["zh-CN"].translation, key)
export const i18n = createInstance()
void i18n.use(initReactI18next).init({
resources,
lng: language,
fallbackLng: false,
...createTranslationDiagnostics(process.env.NODE_ENV !== "production", (message) => console.error(message)),
supportedLngs: ["zh-CN", "en-US"],
initAsync: false,
interpolation: { escapeValue: false },
})
export function switchLanguage(nextLanguage: Locale = i18n.resolvedLanguage === "en-US" ? "zh-CN" : "en-US"): void {
Taro.setStorageSync(languageKey, nextLanguage)
z.config(nextLanguage === "en-US" ? z.locales.en() : z.locales.zhCN())
void i18n.changeLanguage(nextLanguage)
}
资源已经打包在本地,因此这里使用同步初始化 initAsync: false。如果以后改成网络加载资源,需要重新处理初始化等待和加载状态,不能原样照搬。
fallbackLng: false 是这个项目的选择:缺少英文时,不自动显示中文来掩盖资源缺失。运行时诊断来自 common 中的纯函数:
typescript
export function createTranslationDiagnostics(development: boolean, report: (message: string) => void) {
const missing = (message: string): void => {
if (development) throw new Error(message)
report(message)
}
return {
appendNamespaceToMissingKey: true,
parseMissingKeyHandler(key: string): string {
missing(`Missing translation: ${key}`)
return key
},
missingInterpolationHandler(): string {
return ""
},
}
}
缺失 key 和缺失插值在这里有意采用不同处理方式。缺失 key 在开发环境抛错,生产环境记录诊断;可选插值的空值按项目约定显示为空字符串,0、false 仍应正常显示。
静态调用仍然要提供资源声明的参数。例如,已有登录页资源包含 {{language}}:
typescript
const { t } = useTranslation()
const label = t("mini.login.languageSelector", { language })
不要因为 language 可能暂时没有值,就把参数整个省略。参数是否声明由静态检查负责,空值如何展示由运行时策略负责。
Provider 接入后还要处理平台文案
apps/miniprogram/src/app.tsx 已通过 I18nextProvider 传入实例。下面只摘录与多语言有关的部分;原有 Query Provider、网络初始化等应用逻辑继续保留:
tsx
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</I18nextProvider>
页面直接使用 Hook 和项目已有的 TDesign 组件:
tsx
import { useTranslation } from "react-i18next"
export default function ExamplePage() {
const { t } = useTranslation()
return <t-navbar title={t("mini.tabs.home")} fixed={false} safeAreaInsetTop />
}
t-navbar 等 t-* 组件需要沿用项目在 app.config.ts 中的统一注册,以及已有类型声明和资源复制配置。这个示例不是让小程序引入 Web 的 DOM 组件。
React 页面中的翻译会跟随语言切换重新渲染,但微信原生 TabBar 不在这个 React 渲染链路里。项目在应用入口单独更新它,并监听后续语言变化:
typescript
const tabBarValues = ["home", "devices", "map", "alarms", "profile"] as const
const refreshTabBar = (): void => {
tabBarValues.forEach((value, index) => {
void Taro.setTabBarItem({
index,
text: i18n.t(`mini.tabs.${value}`),
})
})
}
useEffect(() => {
refreshTabBar()
i18n.on("languageChanged", refreshTabBar)
return () => i18n.off("languageChanged", refreshTabBar)
}, [])
这里的 index 顺序要与 app.config.ts 的 tabBar.list 一致。配置中的静态文案提供初始值,运行时更新负责显示当前语言;自定义 TDesign TabBar 则应从组件渲染中的 t() 取得文案。
表单校验语言也在 switchLanguage 中同步更新。对于项目使用的 Zod,还需要保留现有入口顺序:app.tsx 的第一项运行时导入是 @/infrastructure/zod,它先设置 jitless: true。后续切换校验消息语言不能重新开启 JIT,也不能将这项初始化挪到业务 Schema 构造之后。
用 TypeScript 约束翻译 key
apps/miniprogram/src/i18next.d.ts 将共享资源和小程序资源提供给 i18next:
typescript
import "i18next"
import type { TranslationShape, sharedResources } from "@repo/common/i18n-resources"
import type { miniProgramResources } from "./i18n-resources"
declare module "i18next" {
interface CustomTypeOptions {
resources: {
translation: TranslationShape<
(typeof sharedResources)["zh-CN"] & {
mini: (typeof miniProgramResources)["zh-CN"]
}
>
}
}
}
保存后,确保该声明文件位于小程序 tsconfig.json 的 include 范围内。当前配置包含 src/**/*.ts 和 src/**/*.tsx。
这层类型约束主要解决静态 key 的可用范围,不应把它理解成已经验证了全部语言的插值一致性。这里使用 TranslationShape 将字符串值拓宽为 string,插值完整性还要交给官方 Lint 检查。
对于接口、配置等外部输入提供的动态字符串,项目使用 requireI18nKey 在平台边界验证后再翻译,而不是用类型断言把任意字符串变成合法 key。类型检查也不能证明任意动态拼接结果都存在。
i18next-cli 负责通用检查
通用部分我交给官方工具:status 检查资源是否完整,lint 检查源码硬编码和翻译调用插值,同时比较不同语言的占位符。
本项目的资源是经过组装的 TypeScript 对象。为了让工具读取到与运行时一致的资源,检查脚本先通过已有 esbuild 将 scripts/i18n-resources.ts 打包执行,再把结果临时写成 JSON。它没有在源码中维护另一份 JSON 翻译。
对应关系如下:
text
共享 TypeScript 资源 + 小程序 TypeScript 资源
↓
组装小程序实际使用的资源
↓
临时目录 mini/zh-CN.json 和 en-US.json
↓
i18next-cli status 和 lint
↓
finally 删除临时目录
配置位于 scripts/i18next.config.mjs,使用一个工厂分别生成 Web 和小程序的检查配置:
javascript
import { defineConfig } from "i18next-cli"
import { join } from "node:path"
function resourceKeys(tree, prefix = "") {
return Object.entries(tree).flatMap(([key, value]) => {
const path = prefix ? `${prefix}.${key}` : key
return typeof value === "string" ? [path] : resourceKeys(value, path)
})
}
export function createI18nextConfig(platform, directory, resources, primaryLanguage) {
return defineConfig({
locales: resources.locales,
extract: {
input:
primaryLanguage === resources.defaultLocale
? [`apps/${platform === "mini" ? "miniprogram" : "web"}/src/**/*.{ts,tsx}`]
: [],
ignore: ["**/*.d.ts"],
output: join(directory, platform, "{{language}}.json"),
primaryLanguage,
functions: ["t", "*.t", "translate"],
},
lint: {
acceptedTags: "all",
checkTranslationPlaceholders: "error",
},
plugins: [
{
name: "assembled-resource-inventory",
onEnd(keys) {
for (const catalog of Object.values(resources.catalogs[platform])) {
for (const key of resourceKeys(catalog)) {
const id = `translation:${key}`
if (!keys.has(id)) keys.set(id, { key, ns: "translation" })
}
}
},
},
],
})
}
这里有几个与项目有关的选择:
acceptedTags: "all"让自定义 JSX 组件也进入文案检查,不能只检查标准 HTML 标签而漏掉 TDesign。checkTranslationPlaceholders: "error"让不同语言的占位符不一致时直接失败,不只打印警告。- inventory 插件把资源中的所有叶子 key 交给官方
status,包括只通过业务描述符、动态映射使用的资源。它只补充检查输入,不自己实现语言间比较。
检查会交换主语言再比较一次资源。原因是这个版本的 status 容许主语言中存在空占位值,而项目要求所有支持语言都完整。
反向检查时只使用完整资源清单,不再次提取源码。直接换成英文主语言后重新分析含 count 的调用,会改变复数展开方式;这与当前运行时默认语言下的提取行为不是一回事。
由于配置需要临时资源,日常入口是 pnpm i18n:check。不要直接在根目录执行没有配置参数的 i18next-cli lint,期待它自动识别这份配置工厂。
自己编写的检查只处理项目约束
官方工具不知道本项目要求"资源文件只能是纯数据",也不知道业务里的 labelKey、消息描述符和枚举映射分别代表什么。这些约束仍然需要项目代码补上。
| 检查 | 保留原因 | 位置 |
|---|---|---|
| 纯数据资源、单一导出、重复属性、空白文案、语言文件列表 | 约束当前 TypeScript 资源的组织方式 | scripts/check-i18n-structure.cjs |
| 共享片段合并冲突、保留的平台键 | 防止对象展开时覆盖业务文案 | scripts/i18n-resources.ts |
统一命名空间、禁止 defaultValue |
项目不允许用调用端兜底掩盖缺失翻译 | scripts/check-i18n-project.cjs |
| 业务描述符、动态标签和枚举映射 | 补充官方工具不了解的领域结构 | scripts/check-i18n-project.cjs |
资源结构检查使用 TypeScript AST,不通过正则猜测整个文件是否合法。下面摘录实际递归校验部分,它依赖外层定义的 ts 和 errors,不是独立运行入口:
javascript
function validateObject(node, filename) {
if (!ts.isObjectLiteralExpression(node)) {
errors.push(`${filename}: translation resources must contain only object literals and strings`)
return
}
const names = new Set()
for (const property of node.properties) {
if (!ts.isPropertyAssignment(property) || !property.name || ts.isComputedPropertyName(property.name)) {
errors.push(`${filename}: translation entries must be explicit properties`)
continue
}
const name = property.name.text
if (names.has(name)) errors.push(`${filename}: duplicate property ${name}`)
names.add(name)
if (ts.isStringLiteral(property.initializer)) {
if (!property.initializer.text.trim()) {
errors.push(`${filename}: translation ${name} must not be blank`)
}
} else {
validateObject(property.initializer, filename)
}
}
}
脚本外层还会校验:除资源组装用的 index.ts 外,每个资源文件只导出一个 const 对象,并使用 as const。这样导入、函数、动态属性和展开表达式都不能混进单个语言资源文件。
合并冲突则必须在片段被覆盖之前检查。scripts/i18n-resources.ts 持有原始共享片段列表,把每个片段的叶子路径加入 Set,遇到重复路径就抛错。只检查最终的 { ...a, ...b } 结果已经来不及,因为 a 中被覆盖的值不在结果里了。
项目约束检查还会遍历应用和 common 领域源码,识别业务描述符中的 key 和参数,以及特定配置中的 labelKey、titleKey、枚举值等。它保留的是这些项目语义,不再自己实现普通 t("key", options) 的通用完整性检查。
对于无法静态分析的参数对象或任意动态 key,静态脚本不能保证穷尽。当前使用明确的枚举映射、类型约束及运行时 key 校验补足,不把"扫描通过"当作所有动态调用都正确的证明。
执行入口和失败处理
根目录 package.json 中的命令为:
json
{
"scripts": {
"i18n:check": "node scripts/check-i18n-structure.cjs && node scripts/check-i18n.mjs"
}
}
保存资源或配置后,在仓库根目录执行:
bash
pnpm i18n:check
check-i18n.mjs 会完成以下流程:结构检查通过后导出实际资源,检查项目约束,分别运行两端的官方 status 和 lint,再执行两端源码及资源导出脚本的 TypeScript 检查。
子进程失败时,执行器会记录失败并继续收集后续阶段的结果,最终返回非零退出码。临时目录在 finally 中删除;不要把 process.exit() 放在清理之前。这里将官方 CLI 放在子进程中运行,也避免它的退出行为绕过父进程的清理。
临时目录清理对应的核心结构是:
javascript
const directory = mkdtempSync(join(tmpdir(), "iot-i18next-"))
try {
// 将实际组装资源写入 directory,生成临时配置并调用官方 CLI。
// 具体执行和失败收集逻辑见 scripts/check-i18n.mjs。
} finally {
rmSync(directory, { recursive: true, force: true })
}
检查过程中不运行 extract、sync 或 types。这些命令可以用于其他翻译工作流,但当前资源由 TypeScript 源文件维护,运行时 key 类型也已经声明,不能为了做检查让工具改写它们。
本次接入时,Web、小程序资源的双向完整性检查共四项通过,两端类型检查也通过。临时异常样例验证了缺失翻译、插值不匹配和硬编码 JSX 会被拒绝,显式传入 undefined、null、0、false 的插值可通过静态检查。
完整命令仍报告了 20 处已有硬编码内容,包括 UI 英文无障碍文案、布尔值、JSON 示例、旗帜符号和坐标范围。需要区分应翻译的文案与技术字面量,再处理各自问题;没有通过添加基线或忽略整批文件让检查变绿。
修改后如何验证小程序
依赖安装完成后,手动启动项目已有的开发构建:
bash
pnpm --filter miniprogram dev
在微信开发者工具中导入 apps/miniprogram 目录。现有 project.config.json 的 miniprogramRoot 为 dist/,使用开发构建产生的目录,不手动修改产物。保存源码后等待开发构建更新,再在开发者工具中重新编译。
静态检查之外,我会把实际微信环境的验证放在语言切换这条操作链上:
- 在微信开发者工具打开本项目小程序,通过登录页或"我的 → 偏好设置"进入语言选择。
- 切换到英文,检查页面标题、按钮、输入提示和底部 TabBar;校验失败时检查消息语言是否同步。
- 返回之前打开的页面,确认文案跟随当前语言,没有因为提前保存翻译结果而停留在旧语言。
- 关闭后重新打开小程序,确认读取上次保存的语言;删除语言存储后,应回到默认中文。
- 检查中英文长文案的换行、Picker 操作,以及包含可选插值的展示。缺失 key 应明确暴露,空值插值不应被误判为缺失翻译。
以上是手动验收步骤。当前记录的验证结果是命令行检查和临时样例结果,微信实际渲染、触控及布局效果仍需在开发者工具和真机中验证。