Vue应用快速进行国际化

Vue应用快速进行国际化

2022年09月12日 阅读:128 字数:2103 阅读时长:5 分钟

记录Vue应用快速国际化,使用脚本提取代码中的中文、批量翻译、生成语言字典、集成vue-i18n、替换源代码等

前言

对现有的Vue项目进行国际化,需要一个个文件提取中文和替换vue-i18n的语法,所以开发一个解决上述问题的项目。

源码:https://gitee.com/timelessq/i18n

功能特性:

  • 提取Vue项目中的中文词条
  • 批量机器翻译
  • 替换Vue项目中的中文
  • 对基于 vue-i18n 的 Vue 项目执行静态分析

使用方法:

用喜欢的包管理工具安装依赖

pnpm install/npm install
  1. 把项目文件放到 src/vue-files
  2. npm run pick 提取中文
  3. 把提取到的中文格式化下放到language-files,主要是处理文件的键名
  4. npm run translate 批量机器翻译,谷歌可能不可以用,这步不是必须的,可以自己人工翻译
  5. npm run replace 把项目中的中文替换成 vue-i18n 的语法
  6. npm run extract 分析丢失的键和未用到的词条

1、提取中文

将源码里面的除注释外的中文进行提取

const fs = require('fs-extra');
const path = require('path');

const filePath = path.join(__dirname, 'src/vue-files')
const blacklist = ['api','assets','icons','locales','styles', 'vendor', 'linkage', 'combined-alarm'] // 忽略的文件夹
// const blacklist = ['assets','styles','vendor', 'w-picker']
const fileExtReg = /\.(js|json|vue)$/
const i18n = {}

/**
 * 提取中文,导出excel
 */
async function setup() {
  await readdir(filePath)

  const distPath = path.join(__dirname, `dist/pick-files/zh-CN.json`)
  
  fs.writeJSON(distPath, i18n, { spaces: 2 })
}

/**
 * 递归读取文件
 * @param {String} filePath 目录
 */
async function readdir(filePath) {
  const files = await fs.readdir(filePath).catch(err => {
    console.error('Error:(readdir)', err)
  })

  for(let i = 0; i < files.length; i ++) {
    const filename = files[i]
    if(blacklist.includes(filename)) {
      continue
    }
    //获取当前文件的绝对路径
    const filedir = path.join(filePath, filename)

    const stats = await fs.stat(filedir).catch(err => {
      console.error('Error:(stat)', err)
    })
    // 是否是文件
    const isFile = stats.isFile()
    // 是否是文件夹
    const isDir = stats.isDirectory()
    if (isFile) {
      if(fileExtReg.test(filename)) {
        await readFile(filedir).catch(() => {})
      }
    }
    // 如果是文件夹
    if (isDir) {
      await readdir(filedir)
    }
  }
}

/**
 * 读取文件,记录中文
 * @param {String} filedir 文件路径
 */
async function readFile(filedir) {
  console.log(filedir)
  const dataStr = await fs.readFile(filedir,'utf-8').catch(err => {
    console.error('Error:(readFile)', err)
  })
  // /.*[\u4e00-\u9fa5]+.*/gi 匹配一整行
  // /[\u4e00-\u9fa5]+{{ \w+ }}[\u4e00-\u9fa5]+|[\u4e00-\u9fa5]+\${\w+}[\u4e00-\u9fa5]+|(?<!\/\/\s.*|<!--\s.*|\*.*)[\u4e00-\u9fa5]+/gi  匹配${}、{{ }}
  // /(?<!\/\/\s.*|<!--\s.*|\*.*)[\u4e00-\u9fa5]+/gi
  const matchArr = dataStr.match(/[\u4e00-\u9fa5]+\s?{{ \w+ }}\s?[\u4e00-\u9fa5]+|[\u4e00-\u9fa5]+\${.*}[\u4e00-\u9fa5]+|(?<!\/\/\s.*|<!--\s.*|\*.*)[\u4e00-\u9fa5]+/gi)
  const uniqArr = Array.from(new Set(matchArr))

  const result = {}
  uniqArr.forEach(item => {
    result[item] = item
  })

  i18n[filedir.replace(filePath, '')] = result
}

setup()

2、机器翻译

方案一使用谷歌翻译Api:@vitalets/google-translate-api

方案二抓取 https://translate.google.cn/m 谷歌翻译移动端页面

const fs = require('fs-extra');
const path = require('path');
const axios = require('axios');
const translate = require('@vitalets/google-translate-api');
const cheerio = require('cheerio')

/**
 * planA 调用谷歌翻译Api (限频次,可能会被封IP)
 * planB 抓取谷歌翻译网页
 * @sumary 建议自己换成百度翻译Api
 */
const mode = 'fetchApi'
const handleTranslate = mode === 'fetchApi' ? fetchApi : cheerioPage

const expectList = [
  { lang: 'en', filename: 'en-US' }
] // 期望生成的语言文件

async function setup() {
  const sourceLang = fs.readJSONSync(path.join(__dirname, 'dist/language-files/zh-CN.json'))
  
  let taskList = []

  const lastGroup = Object.keys(sourceLang).pop()
  const lastKey = Object.keys(sourceLang[lastGroup]).pop()

  for(const item of expectList) {
    item.result = {}

    for(const i in sourceLang) {
      for(const j in sourceLang[i]) {
        taskList.push(
          handleTranslate({
            source: sourceLang[i][j], 
            group: i,
            key: j,
            to: item.lang
          })
        )

        if (taskList.length === 10 || (i === lastGroup && j === lastKey)) {
          await Promise.allSettled(taskList).then(res => {
            res.forEach(d => {
              if (d.status === 'fulfilled') {
                const { group, key, to, text } = d.value

                if (to === item.lang) {
                  if (!item.result[group]) {
                    item.result[group] = {}
                  }
                  item.result[group][key] = text
                }
              }
            })
          }).catch(() => {})
          taskList = []
        }
      }
    }

    fs.writeJSON(path.join(__dirname, `dist/translate-files/${item.filename}.json`), item.result, { spaces: 2 })
  }
 }

/**
 * @param {String} source 要翻译的文本
 * @param {Number} index 下标
 * @param {String} to 目标语言
 * @param {String} from 源语言
 * @returns {Promise}
 */
function fetchApi({ source, group, key, to = 'en', from = 'zh-CN' }) {
  return translate(source, {from, to: 'es', tld: 'cn'}).then(res => {
    let text = res.text
    text = text.replace(text[0], text[0].toLocaleUpperCase())
    console.log(group, key, to, source, text)

    return { group, key, to, text }
  }).catch(err => {
    console.error(err.statusCode);
  });
}


async function cheerioPage({ source, group, key, to = 'en', from = 'zh-CN' }) {
  return axios({
    url: `https://translate.google.cn/m?sl=${from}&tl=${to}&q=${encodeURI(source)}`,
    method: 'get'
  }).then(async res => {
    const html = res.data
    const $ = cheerio.load(html)

    let text = $('.result-container').text()
    text = text.replace(text[0], text[0].toLocaleUpperCase())
    console.log(group, key, to, source, text)

    return { group, key, to, text }
  }).catch(err => {
    console.error(err);
  })
}

setup()

3、替换源代码

这里我们使用中文做key,例如$t(‘登录成功’),有个好处是不用写注释了,而拿英文翻译转成驼峰命名做key可能会很长,而且到时候英文翻译变了key也会变,

替换代码时需要考虑vue的语法

  • html标签:/>(\$t\(.*\))</
  • 元素或组件属性:/ (title|label|alt|placeholder)="\$/
  • 其次是字符串全部添加this上下文:/'(\$t\(.*\))'/
const fs = require('fs-extra');
const path = require('path');

const filePath = path.join(__dirname, 'src/vue-files')
const blacklist = ['api','assets','icons','locales','styles', 'vendor', 'linkage', 'combined-alarm']
// const blacklist = ['assets','styles','vendor', 'w-picker']
const fileExtReg = /\.(js|json|vue)$/
let i18nData = []

/**
 * 提取excel,导出josn,替换源代码
 */
async function setup() {
  const sourceLang = fs.readJSONSync(path.join(__dirname, 'dist/language-files/zh-CN.json'))

  for(const i in sourceLang) {
    for(const j in sourceLang[i]) {
      i18nData.push({
        group: i,
        key: j,
        value: sourceLang[i][j]
      })
    }
  }
  
  // 替换源代码
  readdir(filePath)
}

/**
 * 递归读取文件
 * @param {String} filePath 目录
 */
async function readdir(filePath) {
  const files = await fs.readdir(filePath).catch(err => {
    console.error('Error:(readdir)', err)
  })

  for(let i = 0; i < files.length; i ++) {
    const filename = files[i]
    if(blacklist.includes(filename)) {
      continue
    }
    //获取当前文件的绝对路径
    const filedir = path.join(filePath, filename)

    const stats = await fs.stat(filedir).catch(err => {
      console.error('Error:(stat)', err)
    })
    // 是否是文件
    const isFile = stats.isFile()
    // 是否是文件夹
    const isDir = stats.isDirectory()
    if (isFile) {
      if(fileExtReg.test(filename)) {
        await readFile(filedir).catch(() => {})
      }
    }
    // 如果是文件夹
    if (isDir) {
      await readdir(filedir)
    }
  }
}

/**
 * 读取文件,替换$t
 * @param {String} filedir 文件路径
 */
async function readFile(filedir) {
  const targetPath = filedir.replace('src', 'dist')
  console.log(targetPath)
  const dataStr = await fs.readFile(filedir,'utf-8').catch(err => {
    console.error('Error:(readFile)', err)
  })
  // /.*[\u4e00-\u9fa5]+.*/gi 匹配一整行
  // /[\u4e00-\u9fa5]+{{ \w+ }}[\u4e00-\u9fa5]+|[\u4e00-\u9fa5]+\${\w+}[\u4e00-\u9fa5]+|(?<!\/\/\s.*|<!--\s.*|\*.*)[\u4e00-\u9fa5]+/gi  匹配${}、{{ }}
  // /(?<!\/\/\s.*|<!--\s.*|\*.*)[\u4e00-\u9fa5]+/gi
  const content = dataStr.replace(/(?<!\/\/\s.*|<!--\s.*|\*.*)[\u4e00-\u9fa5]+/gi, (re) => {
    const findLocale = i18nData.find(item => item.value === re)
    if(findLocale) {
      const key = findLocale.key.replace(/\./g, '').trim()
      const result = `$t('${findLocale.group}.${key}')`
      return result
    }
    return re
  }).replace(/>(\$t\(.*\))</gi, (match, re) => {
    // 替换标签内容
    return `>{{ ${re} }}<`
  }).replace(/ (title|label|alt|placeholder)="\$/gi, (re) => { 
    // 替换属性
    return ` :${re.trimStart()}`
  }).replace(/'(\$t\(.*\))'/gi, (match, re) => {
    // 替换JS
    return 'this.' + re
  })

  await fs.ensureFile(targetPath) // 先创建目录
  await fs.writeFile(targetPath, content)
}

setup()

4、集成vue-i18n

ant-design-vue、moment设置国际化

默认语音为中文简体,其他语音通过import延迟或异步加载语言转换文件

axios请求头添加语言参数

import moment from 'moment'
import Vue from 'vue'
import VueI18n from 'vue-i18n'

import zhCN from './lang/zh-CN/index'

import { getLocalStorage } from '@/utils'

Vue.use(VueI18n)

// 默认语言
export const defaultLang = 'zh-CN'
moment.updateLocale(zhCN.momentName, zhCN.momentLocale)

// 语言列表
export const locales = [
  { name: '简体中文', value: 'zh-CN' },
  { name: 'English', value: 'en-US' },
  { name: 'Español', value: 'es-ES' }
]

const messages = {
  'zh-CN': {
    ...zhCN
  }
}

/**
 * 获取客户端语言
 * @returns {String}
 */
export function getLanguage() {
  const storeLanguage = getLocalStorage('language')
  // 如果之前已经选择过语言,直接返回
  if (storeLanguage) {
    return storeLanguage
  }

  // 如果没有选择语言,则根据浏览器信息来设置默认语言
  const language = (navigator.language || navigator.browserLanguage).toLowerCase()
  for (const item of locales) {
    if (item.value.toLowerCase().indexOf(language) > -1) {
      return item.value
    }
  }

  return defaultLang
}

const i18n = new VueI18n({
  // set locale
  // options like: en | zh | es
  locale: defaultLang,
  fallbackLocale: defaultLang,
  // set locale messages
  messages
})

const loadedLanguages = [defaultLang]

/**
 * 设置i18n语言
 * @param {String} lang 语言
 * @returns {String}
 */
function setI18nLanguage(lang) {
  i18n.locale = lang
  document.querySelector('html').setAttribute('lang', lang)
  return lang
}

/**
 * 延迟或异步加载语言转换文件
 * @param {String} lang 语言
 * @sumary 一次加载所有翻译文件是过度和不必要的,加载新文件是通过import功能完成的
 * @see https://kazupon.github.io/vue-i18n/zh/guide/lazy-loading.html
 * @returns {Promise}
 */
export function loadLanguageAsync(lang = getLanguage()) {
  // 如果语言相同
  if (i18n.locale === lang) {
    return Promise.resolve(setI18nLanguage(lang))
  }

  // 如果语言已经加载
  if (loadedLanguages.includes(lang)) {
    return Promise.resolve(setI18nLanguage(lang))
  }

  // 如果尚未加载语言
  return import(/* webpackChunkName: "lang-[request]" */ `./lang/${lang}`).then(msg => {
    const locale = msg.default
    i18n.setLocaleMessage(lang, locale)
    loadedLanguages.push(lang)
    moment.updateLocale(locale.momentName, locale.momentLocale)
    return setI18nLanguage(lang)
  })
}

/**
 * 外部JS文件使用i18n渲染
 * @param {String} key 语言字典键名
 * @returns {Function} i18n.t()
 */
export function i18nRender(key) {
  return i18n.t(`${key}`)
}

export default i18n

5、检查词条使用情况

依赖vue-i18n-extract对 Vue.js 源代码运行静态分析以查找任何vue-i18n用法:

  • 报告语言文件中所有缺失的键。
  • 报告语言文件中所有未使用的密钥
  • 可以选择将每个丢失的键写入您的语言文件。

pnpm install --save-dev vue-i18n-extract

作为Node.js脚本

// extract.js
const VueI18NExtract = require("vue-i18n-extract");

// @see https://github.com/Spittal/vue-i18n-extract
VueI18NExtract.createI18NReport({
  vueFiles: "./dist/vue-files/**/*.?(js|vue)",
  languageFiles: "./dist/language-files/*.?(json|yml|yaml)",
  output: "./dist/extract-output.json",
  // add: true, // 是否添加缺失的词条
  // remove: true, // 是否移除没有用到的词条
  exclude: [ // remove忽略的key
    "all.",
    "example.go"
  ],
});

将源代码放到vue-files目录下,语言文件放到language-files目录,建议使用json文件

执行node extract.js

1bb7873213fe044d.png

推荐阅读

恰饭区

评论区 (0)

0/500

还没有评论,快来抢第一吧