微信小程序 + Taro + 多语言实现与国际化检查实录

微信小程序 + 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/,使用开发构建产生的目录,不手动修改产物。保存源码后等待开发构建更新,再在开发者工具中重新编译。

静态检查之外,我会把实际微信环境的验证放在语言切换这条操作链上:

  1. 在微信开发者工具打开本项目小程序,通过登录页或"我的 → 偏好设置"进入语言选择。
  2. 切换到英文,检查页面标题、按钮、输入提示和底部 TabBar;校验失败时检查消息语言是否同步。
  3. 返回之前打开的页面,确认文案跟随当前语言,没有因为提前保存翻译结果而停留在旧语言。
  4. 关闭后重新打开小程序,确认读取上次保存的语言;删除语言存储后,应回到默认中文。
  5. 检查中英文长文案的换行、Picker 操作,以及包含可选插值的展示。缺失 key 应明确暴露,空值插值不应被误判为缺失翻译。

以上是手动验收步骤。当前记录的验证结果是命令行检查和临时样例结果,微信实际渲染、触控及布局效果仍需在开发者工具和真机中验证。

参考

相关推荐
deli0072 小时前
Redis 缓存穿透、击穿、雪崩:9 组流量实测数据库怎么被打穿
前端
江华森2 小时前
5Mbps 小水管也能扛:K3s 上 k6 压测 + HPA 弹性伸缩实测
前端
shuangyueliao2 小时前
103 个小游戏的商店素材,我一张图都没手动做:截图 / 封面 / 打包 / 提审全自动
前端
ZhengEnCi2 小时前
前端项目为什么需要一份统一的 theme.css:用 CSS 变量管住全局样式与主题切换
前端·css·前端工程化
wordbaby2 小时前
一次微信小程序 `web-view` 跳转原生页回跳后 H5 白屏的排查与修复(Hash 路由污染)
前端
用户721880438352 小时前
密码骰子:结合AIUI在眼镜上生成一串16位密码
前端
江华森3 小时前
全链路可观测落地记:kube-prometheus-stack 在 K3s 上的 8 个坑与一整套修复脚本
前端
deli0073 小时前
27 寸 4K 开 125% 为什么发虚?PPI 和缩放一次算清
前端
广州华水科技3 小时前
当大坝面临风险时,如何有效实施单北斗GNSS变形监测?
前端