最近在负责公司项目的国际化 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
位置,成功后大致文件结构如下
8. 翻译相关文件并提交代码 (推荐使用 git 进行翻译文件的版本管理,可以使用 weblate 统一管理多个项目的翻译资产)
最佳实践
在项目中尽量避免 文件名 或 图标 使用中文路径,除此之外代码变量名函数名、css 类名等也请遵守命名规范切勿使用中文字符串,以免给国际化工作带来额外负担。
遇到问题?
在分离的动态创建的组件 (例如弹窗) 模版语法尽量不要使用 $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>