如何优雅的做项目国际化

687 阅读4分钟

首先,我们项目是通过vben admin搭建的基本框架,然后它内部集成了vue-i18n等国际化库,我们可以直接使用。但是国际化内容是产品将一些翻译输出到excel发给我,这样我就需要将excel内容一个一个的粘贴复制到项目国际化语言包文件中,这样就很恶心了。

国际化实现以前写过一篇文章,切换国际化语言菜单时,最好刷新一下页面location.load()

然后就想着通过excel解析库,去解析内容并输出到对应的文件中,这样就可以很快完成这种枯燥的功能了。

还有就是当我们使用国际化时,我们的语言包也应该有对应模块的区分,不然卸载一个文件夹下,会很臃肿,所以我们要动态去生成语言包对象。

读取excel内容,快速设置语言包

由于我们的文件是加密的,所以需要先解密。

// 上传文件,获取解密code
const uploadChange = async (e) => {
  const file = e.target.files[0];
  const fd = new FormData();
  fd.append("file", file);
  const res = await fetch("解密url", {
    method: "post",
    body: fd,
    headers: {
      "Content-Type": "multipart/form-data",
    },
  }).then((res) => res.json());
  // 获取解密code
  getData(res.data.code);
};

获取完 code 进行解密,并对文件进行分析处理。

  • createObjectURL(File): 根据File类型对象创建一个url,定位资源。
  • revokeObjectURL(objectURL): 释放createObjectURL创建的资源,可以让浏览器知道不需要在保存他的内存。
const getData = (code) => {
  fetch(`解密url?id=${code}`, {
    method: "get",
    responseType: "blob",
  })
    .then((res) => {
      return res.blob();
    })
    .then((blob) => {
      let bl = new Blob([blob], { type: "Application/vnd.ms-excel" });
      let fileName = Date.parse(new Date()) + ".xlsx";
      var link = document.createElement("a");
      link.href = window.URL.createObjectURL(blob);
      link.download = fileName;
      link.click();
      window.URL.revokeObjectURL(link.href);
      // 直接传递给xlsx处理
      transformData(blob);
    });
};

不需要解密我们可以直接获取上传的文件对象,进行解析就可以了。

const uploadChange = async (e) => {
  const file = e.target.files[0];
  // 直接解析文件对象
  transformData(file);
};

数据解析,借助xlsx

  • 首先创建一个文件对象,用于读取文件内容。
  • 将读取的内容通过XLSX.read进行解析。
  • 将解析的内容转化为json格式。
  • 处理数据输出文件,这里我们生成中英文两个 json 文件,方便我们后续操作。
// xlsx处理数据
function transformData(file) {
  // 通过FileReader对象读取文件
  const fileReader = new FileReader();
  // readAsArrayBuffer之后才会启动onload事件
  fileReader.onload = (event) => {
    const data = new Uint8Array(event.target.result);
    const { SheetNames, Sheets } = XLSX.read(data, { type: "array" });
    // 这里只取第一个sheet
    const workSheets = Sheets[SheetNames[0]];
    const sheetRows = XLSX.utils.sheet_to_json(workSheets);
    console.log(sheetRows, "转换的excel");
    // 中文
    const obj = {};
    // 英文
    const enObj = {};

    sheetRows.forEach((item) => {
      // 对英文进行转换,输出属性字段。小驼峰
      const en =
        (item.en + "").replace(/\s/g, "").slice(0, 1).toLowerCase() +
        (item.en + "").replace(/\s/g, "").slice(1);
      obj[en] = item.zh;
      enObj[en] = item.en;
    });
    exportRaw(JSON.stringify(obj), "./zh.json");
    exportRaw(JSON.stringify(enObj), "./en.json");
    // console.log("obj", obj, enObj);
  };
  // uploadFile是上传文件的文件流
  fileReader.readAsArrayBuffer(file);
}

image.png 输出并下载文件。

  • Blob可以通过给定数据,生成一个文件对象。
// 下载转化后的文件
function exportRaw(data, name) {
  let urlObject = window.URL || window.webkitURL || window;
  let export_blob = new Blob([data]);
  let save_link = document.createElementNS("http://www.w3.org/1999/xhtml", "a");
  save_link.href = urlObject.createObjectURL(export_blob);
  save_link.download = name;
  save_link.click();
}

这样一顿操作下来,解放了我的双手,又可以来两把 QQ Fly Car了。

封装useI18n hook

文件结构

image.png

注册i18n

  • 获取当前语言状态,放在全局状态管理中,做响应式。
  • 通过import()动态导入语言包处理文件, 获取语言包语言对象。
  • 返回i18n配置
  • 最后导出一个注册函数,在main.ts文件中经行注册。
import type { App } from 'vue';
import type { I18n, I18nOptions } from 'vue-i18n';
import { createI18n } from 'vue-i18n';


export let i18n: ReturnType<typeof createI18n>;

export const LOCALE: { [key: string]: 'zh_CN' | 'en'} = {
  ZH_CN: 'zh_CN',
  EN_US: 'en',
};

async function createI18nOptions(): Promise<I18nOptions> {
  // TODO: 获取本地缓存的localeLang, 放在全局状态管理中,做响应式
  const locale = localStorage.getItem("localeLang")  || LOCALE.ZH_CN
  // 获取语言包,即语言包处理文件
  const defaultLocal = await import(`./lang/${locale}.ts`);
  // 获取语言对象
  const message = defaultLocal.default?.message ?? {};

  // 返回i18n配置
  return {
    legacy: false,
    locale: locale,
    fallbackLocale: locale,
    messages: {
      [locale]: message,
    },
    availableLocales: [LOCALE.ZH_CN, LOCALE.EN_US],
    sync: true, 
    silentTranslationWarn: true,
    missingWarn: false,
    silentFallbackWarn: true,
  };
}

// 注册
export async function setupI18n(app: App) {
  const options = await createI18nOptions();
  i18n = createI18n(options) as I18n;
  app.use(i18n);
}

处理语言包递归文件

  • 使用vite提供的import.meta.glob方法去动态加载文件。
  • 调用语言包处理函数getMessage,即可读取各个语言包模块内容并生成对应的语言对象。
import { genMessage } from '../helper';
// TODO: 其他库语言包 例如antd
// import antdLocale from 'ant-design-vue/es/locale/en_US';

// 获取en文件夹下的所有ts结尾文件,嵌套文件
const modules = import.meta.glob('./en/**/*.ts', { eager: true });

export default {
  message: {
    // 处理文件,拿出所有嵌套文件语言
    ...genMessage(modules, 'en'),
    // TODO: 其他库语言包 例如antd
    // antdLocale,
  },
};
import { genMessage } from '../helper';
// TODO: 其他库语言包 例如antd
// import antdLocale from 'ant-design-vue/es/locale/zh_CN';

const modules = import.meta.glob('./zh-CN/**/*.ts', { eager: true });
export default {
  message: {
    // 处理文件,拿出所有嵌套文件语言
    ...genMessage(modules, 'zh-CN'),
    // TODO: 其他库语言包 例如antd
    // antdLocale,
  },
};

hook 封装

  • 循环传入的语言包文件模块,处理分析路径。
  • 通过lodash.set方法生成对应路径下的对象。
  • 返回处理好的语言对象。

这样我们就可以区分不同模块的语言包,而不是写在一个文件中,看起来很臃肿了。

// helper.ts
import { set } from 'lodash-es';

export function genMessage(langs: any, prefix = 'lang') {
  const obj: Record<string, any> = {};

  // 循环动态加载的模块路径,并获取内容。
  Object.keys(langs).forEach((key) => {
    const langFileModule = langs[key].default;
   /**
    * TODO:
    *  ./en/common.ts => common.ts
    * ./en/routes/b.ts => routes/b.ts
    */
    let fileName = key.replace(`./${prefix}/`, '').replace(/^\.\//, '');
    const lastIndex = fileName.lastIndexOf('.');
    /**
     * TODO: 
     * common.ts => common
     * routes/b.ts => routes/b
     */
    fileName = fileName.substring(0, lastIndex);
    const keyList = fileName.split('/');
    // 拿到第一层文件夹名称
    /**
     * common
     * routes
     */
    const moduleName = keyList.shift();
    // 拿到最后文件名称
    const objKey = keyList.join('.');
    if (moduleName) {
      if (objKey) {
        /**
         * set(object, path, value)
         * 
         * 设置 object对象中对应 path 属性路径上的值,如果path不存在,则创建。 缺少的索引属性会创建为数组,而缺少的属性会创建为对象。 
         * 
         */
        set(obj, moduleName, obj[moduleName] || {});
        set(obj[moduleName], objKey, langFileModule);
      } else {
        set(obj, moduleName, langFileModule || {});
      }
    }
  });
  return obj;
}

封装useI18n hook,处理t函数参数边界情况,并调用i18n内部的t函数做语言包取参。

import { i18n } from './index';

type I18nGlobalTranslation = {
  (key: string): string;
  (key: string, locale: string): string;
  (key: string, locale: string, list: unknown[]): string;
  (key: string, locale: string, named: Record<string, unknown>): string;
  (key: string, list: unknown[]): string;
  (key: string, named: Record<string, unknown>): string;
};

type I18nTranslationRestParameters = [string, any];

// 区分使用hooks时是否传递通用的前缀
function getKey(namespace: string | undefined, key: string) {
  if (!namespace) {
    return key;
  }
  if (key.startsWith(namespace)) {
    return key;
  }
  return `${namespace}.${key}`;
}

export function useI18n(namespace?: string): {
  t: I18nGlobalTranslation;
} {
  // 标准化 t 中的参数
  const normalFn = {
    t: (key: string) => {
      return getKey(namespace, key);
    },
  };

  if (!i18n) {
    return normalFn;
  }

  // 国际化函数就是在i18n.global中的
  const { t, ...methods } = i18n.global;

  const tFn: I18nGlobalTranslation = (key: string, ...arg: any[]) => {
    // 排除不正确的key,因为国际化传参都是通过`[string].[string]`来取值的
    if (!key) return '';
    if (!key.includes('.') && !namespace) return key;
    return t(getKey(namespace, key), ...(arg as I18nTranslationRestParameters));
  };
  
  return {
    ...methods,
    t: tFn,
  };
}

切换语言更新页面

当切换语言时,我们需要更新三部分内容。

  • i18n locale属性
if (i18n.mode === 'legacy') { 
    i18n.global.locale = locale; 
} else { 
    (i18n.global.locale as any).value = locale; 
}
  • 更新当前语言包message
globalI18n.setLocaleMessage(locale, message);
  • 还有一个html lang属性,有利于SEO和用户体验。
import { useAppStore } from './../store/index';
import { setHtmlPageLang } from "./helper";
import { i18n } from "./index"

import {unref} from "vue"
export type LocaleType = "zh_CN" | "en"

function setI18nLanguage(locale: LocaleType) {
  if (i18n.mode === 'legacy') {
    i18n.global.locale = locale;
  } else {
    (i18n.global.locale as any).value = locale;
  }
  // TODO: 更新全局状态中的语言变量
  useAppStore().changeLocaleLang(locale)
  // 设置html lang属性
  setHtmlPageLang(locale);
}


export function useLocale() {
  /**
 * 切换语言类型调用
 */
  async function changeLocale(locale: LocaleType) {
    const globalI18n = i18n.global;
    // 获取当前语言类型
    const currentLocale = unref(globalI18n.locale);
    if (currentLocale === locale) {
      return locale;
    }

    // 拿到当前语言包message对象
    const langModule = ((await import(`./lang/${locale}.ts`)) as any).default;
    if (!langModule) return;

    const { message } = langModule;


    // 更新i18n语言包
    globalI18n.setLocaleMessage(locale, message);
    // 更新locale
    setI18nLanguage(locale);

    return locale;
  }
  return {
    changeLocale
  }
}

还有一个问题就是,当我们在配置文件进行国际化时,切换语言也不会更新页面(不刷新的情况下),我们可以对语言进行监听,让逻辑重新执行一遍。注意配置文件导出时,需要导出一个函数。

/**
 *
 * @param  {...any} cbs 所有的回调
 */
export function watchSwitchLang(...cbs) {
  watch(
    // TODO: 监听语言变化
    () => store.getters.language,
    () => {
      cbs.forEach((cb) => cb(store.getters.language))
    }
  )
}

具体代码,请参考这里

往期文章

专栏文章

最近也在学习nestjs,有一起小伙伴的@我哦。

已辞职,先玩他个把月,舒服。结束了,一切都结束了。 🎇🎉✨

接上篇总结,近三个月的排错,原来的憧憬消失喽