i18n
Background
前两天做了一个比较有意思的需求,一个项目既需要支持多渠道部署也要支持多语言切换。不同渠道之间大约 80% 的功能都是一样的,只有部分功能比如登录是单独定制的。看到这个要求,就立刻能想到用 monorepo
管理再适合不过了。
项目结构
要支持多渠道部署,也就是有多个应用入口,为了方便管理,所有应用入口都放在目录 app
下。比如我们有 3 个渠道 A
,B
,C
,项目结构如下
- project
- pnpm-workspace.yaml
- app
- channelA
- src
- main.ts
- App.vue
- index.html
- channelB
- channelC
因为 80% 的功能都是一样且 UI 布局也是基本一样,只有部分功能是独有比如登录、支付等,所以我们要抽出通用的功能和组件等,这一部分我们放在 packages
里,具体划分如下
- project
- pnpm-workspace.yaml
- app
- packages
- api
- common // 通用接口
- channelA // 业务独有
- channelB
- channelC
- components
- hooks
- locales
- channelA
- channelB
- channelC
- store
- utils
api
层可以放在项目级别,也可以放在 packages
,考虑到会有公共的api,所以干脆把 api
也按渠道划分,通用接口则放在 common
。举个例子
// packages/api/channelA/xx.ts
import { request } from './inject'
export default {
queryList(data: { url: string }) {
return request.post({ url: `/query/list`, data })
},
}
可以看到,api
仅维护一堆接口列表,真正的 request
来自业务,所以才有一个 inject
。这个 inject
也比较简单,在业务入口初始化一个 axios
实例,传进来即可
// app/channelA/src/main.ts
import request from '@/utils/http'
async function bootstrap() {
const app = createApp(App)
injectRequest(request)
}
components/hooks/store
之间是有依赖的,通用的组件逻辑抽象到这里,不再赘述。剩下的就是 locales
,多语言实现了。
locales
类型定义
首先,定义2个 enum
:渠道和语言,目前业务只支持中文和英文
export enum LocaleType {
EN_US = 'en',
ZH = 'zh'
}
export const LOCALE_CHANNEL = {
CHANNEL_A: 'channel_a'
CHANNEL_B: 'channel_b',
CHANNEL_C: 'channel_c',
}
可见,在这个例子就有 2*3=6
种配置项。
按渠道缓存
此外,因为 locale
放在浏览器 localStorage
,不同渠道要有自己对应的 key
,这里我们就用渠道作为 key
足以。
// helper.ts
// 缓存
let cachedKey = ''
// 注意,这里用 渠道 作为缓存的 key
const setCacheKey = (prefix = '') => {
cachedKey = prefix + '_' + 'locale'
}
export function getLangCache() {
if (!cachedKey) return
let data
try {
data = localStorage.getItem(cachedKey)
} catch (e) {
console.log('getLangCache', e)
}
return data
}
function setLangCache(s: string) {
localStorage.setItem(cachedKey, s)
}
export function removeLangCache() {
localStorage.removeItem(cachedKey)
}
// 配置渠道
let channel = ''
export const setChannel = (ch: LOCALE_CHANNEL) => {
channel = ch
setCacheKey(channel)
}
// 校验渠道是否合法
function isValidChannel() {
return channel && Object.values(LOCALE_CHANNEL).includes(channel)
}
默认语言
此外,业务还要配置该渠道的默认语言
// helper.ts
let defaultLocale = ''
export const setDefaultLocale = (v: string) => (defaultLocale = v)
export const getDefaultLocale = () => defaultLocale
初始化语言包
综上,我们就可以初始化语言了
export async function setupI18n(app: App, options: I18nOptions, cfg: { channel: string }) {
if (!options.locale || !cfg.channel) {
throw new Error('both default locale and channel are required')
}
// 使用渠道作为缓存key
setChannel(cfg.channel)
// 指定该渠道的默认语言
setDefaultLocale(options.locale)
i18n = createI18n({
locale: LocaleType.EN_US,
legacy: false // 使用 composition api
})
// 优先从缓存读取,其次是默认语言
await changeLocale((getLangCache() || options.locale) as LocaleType, true)
app.use(i18n)
return i18n
}
组件中使用语言
// index.ts
export const useLocales = () => {
const { t } = useI18n()
return t
}
前面配置了 legacy: false
所以这里我们可以使用 composition API
<div>{{t('upload')}}</div>
<script setup>
import { useLocales } from '@xx/locales'
const { t } = useLocales()
</script>
切换语言
前面的代码中有个陌生的函数 changeLocale()
,用来切换语言的,具体代码是参考 官方文档
export async function changeLocale(locale: LocaleType, force?: boolean) {
if (!force && i18n.global.locale === locale) {
return locale
}
if (!force && cachedLocale.includes(locale)) {
setI18nLang(i18n, locale)
return locale
}
const ok = await loadLocale(i18n, locale)
if (ok) {
setI18nLang(i18n, locale)
return locale
}
}
export const cachedLocale: LocaleType[] = []
export async function loadLocale(i18n: I18n, locale: LocaleType) {
if (!isValidChannel()) return
// 懒加载
const langModule = (await import(`./${channel}/${locale}.ts`)).default
if (!langModule) return
console.log(`${locale} module`, langModule?.message)
cachedLocale.push(locale)
i18n.global.setLocaleMessage(locale, langModule.message)
return locale
}
export function setI18nLang(i18n: I18n, locale: LocaleType) {
if (i18n.mode === 'legacy') {
i18n.global.locale = locale
} else {
// @ts-ignore
i18n.global.locale.value = locale
}
setLangCache(locale)
currentLang.value = locale
document.querySelector('html')?.setAttribute('lang', locale)
}
Message
最后,每个语言对应的 json
是如何组织的呢?我们知道 i18n message
是个对象
const messages = {
en: {
message: {
hello: 'hello world'
}
},
ja: {
message: {
hello: 'こんにちは、世界'
}
}
}
在实际开发中,如果这么写下去,这个 json
会很难维护。一种方法是按某种规则划分,在想办法整合为这种形式。
首先,每个语言独立拆分,然后再按页面功能模块划分,如下
- channelA
- en
- common.ts // 比如 okText
- component.ts
- sys.ts
- zh
- common.ts
- component.ts
- sys.ts // api 接口层
common.ts
就是通用的字段,比如 okText, cacelText, loadingText
等
component.ts
就是业务中各组件的字段,比如
export default {
// 上传
upload: {
uploadText: '上传图片',
acceptType: 'PNG、JPG',
maxSize: 20,
},
// 下载
download: {
downloadText: '下载'
},
}
sys.ts
就是接口层了
export default {
api: {
// 通用兜底
timeout: '网络超时,请稍后重试',
requestFailed: '服务异常,请稍后重试',
networkException: '网络异常,请稍后重试',
// 业务错误码定制信息
// 通用的异常码
[BizCodeEnum.FAILED]: '服务异常,请稍后重试',
[BizCodeEnum.TIMEOUT]: '网络超时,请稍后重试',
[BizCodeEnum.NETWORK_ERROR]: '网络异常,请稍后重试',
[BizCodeEnum.TOKEN_EXPIRED]: '登录失效,请重新登录',
[BizCodeEnum.EMPTY_EMAIL]: '邮箱名称不能为空',
},
现在信息都被模块掉了,剩下的就是怎么组装了
const modules: any = import.meta.glob('./en/**/*.ts', { eager: true })
export default {
message: {
...genMessage(modules, 'en')
}
}
重点是 genMessage()
export function genMessage(langs: Record<string, Record<string, any>>, prefix = 'lang') {
const obj: any = {}
// ./en/common.ts
Object.keys(langs).forEach((key) => {
const langFileModule = langs[key].default
let fileName = key.replace(`./${prefix}/`, '').replace(/^\.\//, '')
const lastIndex = fileName.lastIndexOf('.')
fileName = fileName.substring(0, lastIndex)
const keyList = fileName.split('/')
const moduleName = keyList.shift()
const objKey = keyList.join('.')
if (moduleName) {
if (objKey) {
set(obj, moduleName, obj[moduleName] || {})
set(obj[moduleName], objKey, langFileModule)
} else {
set(obj, moduleName, langFileModule || {})
}
}
})
return obj
}
组件库的国际化
一般我们用 antd-design-vue
的都会用 ConfigProvider
,这个组件是用来给所有的 vue components
提供配置项,这其中就包括了国际化的配置
<a-config-provider :locale="enUS">
<a-pagination :total="50" show-size-changer />
</a-config-provider>
import enUS from 'ant-design-vue/es/locale/en_US';
import zhCN from 'ant-design-vue/es/locale/zh_CN';
查看源码,发现是使用了 provider/inject
,同时 locale
提供了2个组件 <LocaleReceiver />
和 <LocaleProvider />
,从名字就能看出,provider
提供语言环境,reveiver
用来消费
先看 provider
源码,比较简单,就是把业务提供的语言包通过 provide
导出去
setup() {
// 这里的 props.locale 就是前面业务通过 `a-config-provider` 传入的
const state = reactive({
antLocale: {
...props.locale,
}
})
watch(
() => props.locale,
locale => {
state.antLocale = {
...locale,
}
},
{ immediate: true }
)
provide('localeData', state)
// <template><slot /></template>
return () => {
return slots.default?.()
};
}
receiver
稍微比较复杂,主要是考虑了兜底默认值,本质这个组件就是拿到依赖的语言,传入到子组件即可
setup(props) {
// 业务提供的语言环境
const localeData = inject('localeData', {})
const locale = computed(() => {
const locale = props.defaultLocale || defaultLocaleData[props.componentName || 'global'];
const localeFromContext = componentName && antLocale ? localeData.antLocale[componentName] : {};
return {
...(typeof locale === 'function' ? locale() : locale),
...(localeFromContext || {}),
};
});
// <template><slot :localeData="locale.value" :localeCode="localeCode.value" :antLocale="localeData.antLocale" /></template>
return () => {
const children = props.children || slots.default
return children?.(locale.value, localeCode.value, localeData.antLocale)
}
}
再看组件是如何更新的,比如写一个简单的 header.vue
<template>
<LocaleReceiver compName="header">
<template v-slot="{ localeData }">
<h1>hello {{ localeData.back }}</h1>
</template>
</LocaleReceiver>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import LocaleReceiver from '@/components/LocalesReceiver.vue'
</script>
上面这种写法比较麻烦,其实我们想要的就是组件本身需要的 locales
,那可以封装一个 hooks
即可
function useLocaleReceiver(compName, defaultLocale, propsLocale) {
// 业务提供的语言环境
const localeData = inject('localeData', {})
return computed(() => {
const localeFromContext = compName && localeData.antLocale ? localeData.antLocale[compName] : {};
return {
...defaultLocale,
...localeFromContext,
...propsLocale,
}
})
}
此时 header.vue
要改写为如下
<template>
<h1>hello {{ compLocale.back }}</h1>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useLocaleReceiver } from './useReceiver'
const compLocale = useLocaleReceiver('header')
</script>
Under the hood
最后一个问题,i18n
是怎么更新语言的?毕竟我们没有显示的使用 config-provider
。