Vue应用快速进行国际化

Vue应用快速进行国际化

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

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

前言

一个中大型的Vue单页应用,项目开发后期需要实现国际化,支持中文、英文、西班牙语。

1、提取中文

将源码里面的除注释外的中文提取到excel

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

const env = 'web'

const filePath = path.join(__dirname, `src/${env}`)
const blacklist = ['api','assets','icons','locales','styles', 'vendor', 'linkage', 'combined-alarm']
const fileExtReg = /\.(js|json|vue)$/
const i18n = {}
const total = {}

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

  const data = []
  const common = []
  let index = 0
  for(const key in total) {
    if(total[key] > 1) {
      data.push({
        page: index ? '' : '公共',
        cn: key
      })
      common.push(key)
      index++
    }
  }

  i18n.common = common

  for(const key in i18n) {
    const items = i18n[key].filter(item => !common.includes(item))
    if(key !== 'common') {
        i18n[key] = items
    }
    items.forEach((item, index) => {
      data.push({
        page: index ? '' : key,
        cn: item
      })
    })
  }

  exportJsonToExcel(data)
}

/**
 * 递归读取文件
 * @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))
  uniqArr.forEach(item => {
    if(total[item]) {
      total[item] = total[item] + 1
    } else {
      total[item] = 1
    }
  })
  i18n[filedir.replace(filePath, '')] = uniqArr
}

function exportJsonToExcel(data) {
  const workbook = new ExcelJS.Workbook()
  const worksheet = workbook.addWorksheet('My Sheet')

  const columns = [
    { header: '页面', key: 'page', width: 60},
    { header: '中文', key: 'cn', width: 50},
    { header: '英文', key: 'en', width: 50}
  ]
  worksheet.columns = columns
  worksheet.addRows(data)

  const header = worksheet.getRow(1)
  columns.forEach((item, index) => {
    // 设置表头属性
    const headerCell = header.getCell(item.key)
    // 字体
    headerCell.font = {
      color: { argb: 'FFFFFFFF' }
    }
    // 对齐
    headerCell.alignment = { horizontal: 'center' }
    // 填充
    headerCell.fill = {
      type: 'pattern',
      pattern: 'solid',
      fgColor: { argb: 'FF808080' }
    }
  })

  workbook.xlsx.writeFile(`dist/${env}/i18n.xlsx`);
}

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 ExcelJS = require('exceljs');
const translate = require('@vitalets/google-translate-api');
const cheerio = require('cheerio')

const mode = 'b'
const handleTranslate = mode === 'a' ? fetchApi : cheerioPage

/**
 * planA 调用谷歌翻译Api
 * @sumary 限频次,可能会被封IP
 * planB 抓取谷歌翻译网页
 */
async function setup() {
  const { columns, data } = await readExcelToJson(path.join(__dirname, `dist/web/i18n.xlsx`))
  
  let taskList = []
  for(let i = 0; i < data.length; i++) {
    const item = data[i]
    if(!item['英文']) {
      taskList.push(
        handleTranslate({
          source: item['中文'], 
          index: i
        })
      )
    }
    if(!item['西班牙语']) {
      taskList.push(
        handleTranslate({
          source: item['中文'], 
          index: i,
          to: 'es'
        })
      )
    }

    if(taskList.length === 10 || i === data.length - 1) {
      await Promise.allSettled(taskList).then(res => {
        res.forEach(d => {
          if(d.status === 'fulfilled') {
            const value = d.value
            const lang = value.to === 'es' ? '西班牙语' : '英文'
            data[value.index][lang] = value.text
          }
        })
      }).catch(() => {})
      taskList = []
    }
  }

  exportJsonToExcel(data)
 }

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

 async function readExcelToJson(filename) {
  let columns = []
  const data = []

  const workbook = new ExcelJS.Workbook()
  await workbook.xlsx.readFile(filename)

  const worksheet = workbook.getWorksheet(1) // 获取第一个worksheet
  worksheet.eachRow(function(row, rowNumber) {
    const rowValues = row.values
    rowValues.shift()
    if (rowNumber === 1) {
      columns = rowValues
    } else {
      const sheetToJson = {}
      rowValues.forEach((item, index) => {
        sheetToJson[columns[index]] = item
      })
      if(sheetToJson['页面'] === '') {
        const last = data[data.length - 1]
        sheetToJson['页面'] = last ? last['页面'] : ''
      }
      data.push(sheetToJson)
    }
  })

  return { columns, data }
}

async function cheerioPage({ source, index, 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)

    const text = $('.result-container').text()

    console.log(index, to, source, text)
    return {
      index,
      to,
      text: text.replace(text[0], text[0].toLocaleUpperCase())
    }
  }).catch(err => {
    console.error(err);
  })
}

function exportJsonToExcel(data) {
  const workbook = new ExcelJS.Workbook()
  const worksheet = workbook.addWorksheet('My Sheet')

  const columns = [
    { header: '页面', key: '页面', width: 60},
    { header: '中文', key: '中文', width: 50},
    { header: '英文', key: '英文', width: 50},
    { header: '西班牙语', key: '西班牙语', width: 50}
  ]
  worksheet.columns = columns
  worksheet.addRows(data)

  const header = worksheet.getRow(1)
  columns.forEach((item, index) => {
    // 设置表头属性
    const headerCell = header.getCell(item.key)
    // 字体
    headerCell.font = {
      color: { argb: 'FFFFFFFF' }
    }
    // 对齐
    headerCell.alignment = { horizontal: 'center' }
    // 填充
    headerCell.fill = {
      type: 'pattern',
      pattern: 'solid',
      fgColor: { argb: 'FF808080' }
    }
  })

  workbook.xlsx.writeFile(`dist/web/translate-base-5.xlsx`);
}

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 ExcelJS = require('exceljs');

const env = 'web'

const filePath = path.join(__dirname, `src/${env}`)
const blacklist = ['api','assets','icons','locales','styles', 'vendor', 'linkage', 'combined-alarm']
const fileExtReg = /\.(js|json|vue)$/
let excelData = []

/**
 * 提取excel,导出josn,替换源代码
 */
async function setup() {
  const { columns, data } = await readExcelToJson(path.join(__dirname, `dist/${env}/translate.xlsx`))
  excelData = data

  const i18n = []
  columns.forEach(item => {
    if(item !== '页面') {
      i18n.push({
        lang: item,
        locals: {}
      })
    }
  })

  data.forEach((item,index) => {
    i18n.forEach(i => {
      // let key = item['英文'] ? item['英文'].replace(/[ |-]+(\w)/g, (match, re) => {
      //   return re.trim().toUpperCase()
      // }).replace(/\W/g, '') : index
      // if(key[1] && key[1] !== key[1].toUpperCase()) {
      //   key = key.replace(key[0], key[0].toLowerCase())
      // }
      const key = item['中文'].replace(/\./g, '').trim()

      const value = item[i.lang] ? item[i.lang].trim() : ''
      if(i.locals[item['页面']]) {
        i.locals[item['页面']][key] = value
      } else {
        i.locals[item['页面']] = {
          [key]: value
        }
      }
    })
  })

  // 生成JSON字典
  i18n.forEach(i => {
     console.log('generate:', i.lang)
     fs.writeJSON(path.join(__dirname, `dist/${env}/${i.lang}.json`), i.locals, { spaces: 2 })
  })
  
  // 替换源代码
  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').replace(env, `${env}\\code`)
  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 = excelData.find(item => item['中文'] === re)
    if(findLocale) {
      const key = findLocale['中文'].replace(/\./g, '').trim()
      const result = `$t('${findLocale['页面']}.${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) => {
    return 'this.' + re
  })

  await fs.ensureFile(targetPath)
  await fs.writeFile(targetPath, content)
}

async function readExcelToJson(filename) {
  let columns = []
  const data = []

  const workbook = new ExcelJS.Workbook()
  await workbook.xlsx.readFile(filename)

  const worksheet = workbook.getWorksheet(1) // 获取第一个worksheet
  worksheet.eachRow(function(row, rowNumber) {
    const rowValues = row.values
    rowValues.shift()
    if (rowNumber === 1) {
      columns = rowValues
    } else {
      const sheetToJson = {}
      rowValues.forEach((item, index) => {
        sheetToJson[columns[index]] = item
      })
      if(sheetToJson['页面'] === '') {
        const last = data[data.length - 1]
        sheetToJson['页面'] = last ? last['页面'] : ''
      }
      data.push(sheetToJson)
    }
  })

  return { columns, data }
}

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');

const report = VueI18NExtract.createI18NReport({
  vueFiles: './vue-files/**/*.?(js|vue)',
  languageFiles: './language-files/*.?(json|yml|yaml|js)',
  add: false, // 是否将缺少的翻译键添加到您的语言文件中
  remove: true // 是否需要删除语言文件中未使用的翻译键
});

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

执行node extract.js

1bb7873213fe044d.png

推荐阅读

恰饭区

评论区 0

0/500

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