最近在负责公司项目的国际化 i18n 翻译相关的技术,在 Vue 和 Node 这边就直接使用 i18next 来做这块的适配了,当然除此之外还有 golang 以及 android 和 ios 原生部分,这个后面细说。

今天主要分享一下 Vue3 前端部分适配 i18n 整个流程,以及踩到的坑。

开始适配

1. 安装 i18next 相关依赖

npm install i18next i18next-browser-languagedetector i18next-http-backend

npm install --dev @lazycatcloud/i18next-parser

# i18next-vue 仅 vue 项目需要
npm install i18next-vue

2. 创建 src/i18n/index.ts 文件

import i18next from "i18next"
import type { InitOptions, Callback, i18n } from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import Backend from "i18next-http-backend"

export { default as i18next } from "i18next"
export * from "i18next"

// i18next-vue 仅 vue 项目引入
export { default as I18NextVue } from "i18next-vue"
export * from "i18next-vue"

export async function init(
  options?: InitOptions,
  callback?: Callback,
  i18n: i18n | undefined = i18next
) {
  return await i18n
    .use(Backend)
    .use(LanguageDetector)
    .init(
      {
        // lng: "zh",
        debug: false,
        fallbackLng: "zh",
        backend: {
          loadPath: "/languages/{{lng}}/{{ns}}.json"
        },
        ...options
      },
      callback
    )
}

export default {
  init
}

3. 在 src/main.ts 中导入 i18next

import { createApp } from "vue"
import App from "./App.vue"
// ……
import i18n, { i18next, I18NextVue } from "./i18n"

i18n.init({ lng: "en" }).catch((err) => console.error("i18n load failed", err))
i18next
  .changeLanguage("en")
  .catch((err) => console.error("i18n changeLanguage failed", err))

const app = createApp(App)
const pinia = createPinia()
app.use(I18NextVue, { i18next })
// ……
app.mount("#app")

4. 在项目 jsx、tsx、js、ts、vue 代码中使用 t() 翻译函数替换原有的固定字符串

国际化函数的参数解释:

t(
   "view.my.about.text_copyright",
   "Copyright @{{year}}", 
   { year: new Date().getFullYear() }
)

第一个参数 view.my.about.text_copyright 为翻译标识符 (注意: 该标识符在代码中必须保证唯一),推荐写法为 ${代码文件路径} + ${文本在代码文件中的唯一标识},其中路径分隔符使用 . 、全部小写单词之间分隔符使用 _

示例: src/view/my/about.vue 中的 text_copyright 字符串,标识符可以定义为 view.my.about.text_copyright

第二个参数 Copyright @{{year}} 为翻译回退默认值,其中需要拼接参数值时使用 @{{key}} 语法

第三个参数 { year: new Date().getFullYear() } 为拼接参数值,与默认值中的 key 对应 (注意:不要使用 count 作为参数 key 该值为 i18next 保留值,用于生成复数形式的翻译)

<script lang="ts" setup>
import { computed, ref } from "vue"
import type { CSSProperties } from "vue"
import { t } from '@/i18n'

const copyright = t("view.my.about.text_copyright", "Copyright @{{year}}", { year: new Date().getFullYear() })
const company = t("view.my.about.text_company", "武汉锂钠氪锶科技有限公司 版权所有")

const elText = computed(() => {
    return canUpdate.value ? t('view.my.about.some_new_version', "发现新版本") : t("view.my.about.not_new_version", "暂无更新")
})
</script>

<template>
    <div class="page">
        <!-- FIXME: https://github.com/i18next/i18next-parser/issues/617#issuecomment-2179309042 -->
        <AppBar :title="t('view.my.about.page_title', '关于我们')" />
        <div>
            <div>
                <span>{{ t('view.my.about.app_name', '懒猫通讯录') }}</span>
            </div>
            <div>
                <Item :name="$t('view.my.about.current_version', '当前版本')">
                    <template #right>
                        <span class="text-[14px] font-medium text-[#999]">
                            {{ version }}
                        </span>
                    </template>
                </Item>
                <Item :name="t('view.my.about.version_update', '版本更新')">
                    <template #right>
                        <span>
                            {{ elText }}
                        </span>
                    </template>
                </Item>
            </div>

            <div>
                <span>{{ copyright }}</span>
                <span>{{ company }}</span>
            </div>
        </div>
    </div>
</template>

5. 在 package.json 同级目录下创建 i18next-parser.config.js 配置文件如下

export default {
    contextSeparator: '_',
    // Key separator used in your translation keys

    createOldCatalogs: true,
    // Save the \_old files

    defaultNamespace: 'translation',
    // Default namespace used in your i18next config

    defaultValue: '',
    // Default value to give to keys with no value
    // You may also specify a function accepting the locale, namespace, key, and value as arguments

    indentation: 2,
    // Indentation of the catalog files

    keepRemoved: false,
    // Keep keys from the catalog that are no longer in code
    // You may either specify a boolean to keep or discard all removed keys.
    // You may also specify an array of patterns: the keys from the catalog that are no long in the code but match one of the patterns will be kept.
    // The patterns are applied to the full key including the namespace, the parent keys and the separators.

    keySeparator: '.',
    // Key separator used in your translation keys
    // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.



    // see below for more details
    lexers: {
        hbs: ['HandlebarsLexer'],
        handlebars: ['HandlebarsLexer'],

        htm: ['HTMLLexer'],
        html: ['HTMLLexer'],

        vue: [
            {
                lexer: 'JavascriptLexer',
                functions: ['t', '$t'], // Array of functions to match
                namespaceFunctions: ['useTranslation', 'withTranslation'],
            },
            {
                lexer: 'HTMLLexer',
                functions: ['t', '$t'],
                vueBindAttr: true,
            },
        ],
        mjs: ['JavascriptLexer'],
        js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer
        ts: ['JavascriptLexer'],
        jsx: ['JsxLexer'],
        tsx: ['JsxLexer'],

        default: ['JavascriptLexer'],
    },

    lineEnding: 'auto',
    // Control the line ending. See options at https://github.com/ryanve/eol

    locales: ['en', 'zh'],
    // An array of the locales in your applications

    namespaceSeparator: ':',
    // Namespace separator used in your translation keys
    // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance.

    output: 'public/languages/$LOCALE/$NAMESPACE.json',
    // Supports $LOCALE and $NAMESPACE injection
    // Supports JSON (.json) and YAML (.yml) file formats
    // Where to write the locale files relative to process.cwd()

    pluralSeparator: '_',
    // Plural separator used in your translation keys
    // If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys.
    // If you don't want to generate keys for plurals (for example, in case you are using ICU format), set `pluralSeparator: false`.

    input: 'src/**/*.{js,ts,jsx,tsx,vue}',
    // An array of globs that describe where to look for source files
    // relative to the location of the configuration file

    sort: true,
    // Whether or not to sort the catalog. Can also be a [compareFunction](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort#parameters)

    verbose: true,
    // Display info about the parsing including some stats

    failOnWarnings: false,
    // Exit with an exit code of 1 on warnings

    failOnUpdate: false,
    // Exit with an exit code of 1 when translations are updated (for CI purpose)

    customValueTemplate: null,
    // If you wish to customize the value output the value as an object, you can set your own format.
    //
    // - ${defaultValue} is the default value you set in your translation function.
    // - ${filePaths} will be expanded to an array that contains the absolute
    //   file paths where the translations originated in, in case e.g., you need
    //   to provide translators with context
    //
    // Any other custom property will be automatically extracted from the 2nd
    // argument of your `t()` function or tOptions in <Trans tOptions={...} />
    //
    // Example:
    // For `t('my-key', {maxLength: 150, defaultValue: 'Hello'})` in
    // /path/to/your/file.js,
    //
    // Using the following customValueTemplate:
    //
    // customValueTemplate: {
    //   message: "${defaultValue}",
    //   description: "${maxLength}",
    //   paths: "${filePaths}",
    // }
    //
    // Will result in the following item being extracted:
    //
    // "my-key": {
    //   "message": "Hello",
    //   "description": 150,
    //   "paths": ["/path/to/your/file.js"]
    // }

    resetDefaultValueLocale: null,
    // The locale to compare with default values to determine whether a default value has been changed.
    // If this is set and a default value differs from a translation in the specified locale, all entries
    // for that key across locales are reset to the default value, and existing translations are moved to
    // the `_old` file.

    i18nextOptions: null,
    // If you wish to customize options in internally used i18next instance, you can define an object with any
    // configuration property supported by i18next (https://www.i18next.com/overview/configuration-options).
    // { compatibilityJSON: 'v3' } can be used to generate v3 compatible plurals.

    yamlOptions: null,
    // If you wish to customize options for yaml output, you can define an object here.
    // Configuration options are here (https://github.com/nodeca/js-yaml#dump-object---options-).
    // Example:
    // {
    //   lineWidth: -1,
    // }
}

6. 在 package.json 中添加 scripts i18n:parser

{
  "name": "i18n-demo",
  // ……
  "scripts": {
    // ……
    "i18n:parser": "npx @lazycatcloud/i18next-parser -c i18next-parser.config.js",
  },
  // ……
  "dependencies": {
    "i18next": "^24.1.0",
    "i18next-browser-languagedetector": "^8.0.2",
    "i18next-http-backend": "^3.0.1",
    "i18next-vue": "^5.0.0",
    // ……
  },
  "devDependencies": {
    "@lazycatcloud/i18next-parser": "^9.0.11",
    // ……
  }
  // ……
}

7. 执行 npm run i18n:parser 提取全部待翻译的文本内容到 public/languages/$LOCALE/$NAMESPACE.json 位置,成功后大致文件结构如下

Vue3 项目 i18next 国际化落地方案-天真的小窝

8. 翻译相关文件并提交代码 (推荐使用 git 进行翻译文件的版本管理,可以使用 weblate 统一管理多个项目的翻译资产)

最佳实践

在项目中尽量避免 文件名图标 使用中文路径,除此之外代码变量名函数名、css 类名等也请遵守命名规范切勿使用中文字符串,以免给国际化工作带来额外负担。

Vue3 项目 i18next 国际化落地方案-天真的小窝

遇到问题?

在分离的动态创建的组件 (例如弹窗) 模版语法尽量不要使用 $t 函数,否则很可能出现如下报错:

runtime-core.esm-bundler.js:268 Uncaught TypeError: _ctx.$t is not a function
    at Proxy._sfc_render (ContactEmpty.vue:9:49)

解决方法如下,直接使用 import { t } from '@/i18n' 导出 t 函数使用翻译

<script setup lang="ts">
  import EmptyBg from "@/icons/ic_empty_status.png"
  import { t } from '@/i18n'
</script>

<template>
  <div
    class="w-full h-full flex flex-col items-center justify-center pb-50px bg-transparent">
    <img :src="EmptyBg" class="w-220px h-170px" />
    <span class="text-#999 text-12px mt-24px">{{t('view.contact.components.contact_empty.tips', '暂无内容')}}</span>
  </div>
</template>

在 Vue 的 template 语法中使用 i18n 变量报错,需要使用转义字符串

正确转义示例如下,注意在 i18next 中 `count` 是保留字段请使用别的字段代替

<template>
  <div
    class="w-full py-12px px-16px mt-9px bg-#f7f8f9 z-2 flex items-center space-x-2px">
    <span class="font-400 text-14px leading-20px text-left text-#828282">
      {{ t("view.contact.components.contact_list_count.desc", "\{\{counts\}\}人", { counts: count } ) }}
    </span>
    <slot />
  </div>
</template>