Background

前两天做了一个比较有意思的需求,一个项目既需要支持多渠道部署也要支持多语言切换。不同渠道之间大约 80% 的功能都是一样的,只有部分功能比如登录是单独定制的。看到这个要求,就立刻能想到用 monorepo 管理再适合不过了。

项目结构

要支持多渠道部署,也就是有多个应用入口,为了方便管理,所有应用入口都放在目录 app 下。比如我们有 3 个渠道 ABC,项目结构如下

- 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

Refer