用 AI 零侵入实现国际化

162 阅读3分钟

项目背景

近期我们想要将项目实现国际化,由于项目比较大,如果每个地方都去修改一下使用 i18n 替换一下就太麻烦了。还有引用了第三方的 npm 包,第三方包无法直接实现国际化,于是想着能不能通过 AI 来实现国际化。

实现思路

  1. 在最终打包完成后,将所有的中文文案都提取出来,然后将其替换为翻译函数,如:$t('key')
  2. 将提取出来的文案合并相同的内容(减少翻译量),通过 AI 将其翻译成其他语言(这里可以一次实现任意多种语言的翻译)
  3. 将翻译后的文案以语言分类为 json 文件,如:en.json、zh.json、ja.json,json 的文件结构如下:{ key: 'v', key2: 'v2' }
  4. 在 index.html 中插入一段翻译函数,用于读取当前用户的语言,然后先加载对应语言的 json 文件,再插入 script 标签来加载入口函数

实现步骤

1. 提取中文文案

读取所有 js 文件,利用@babel/parser 将其解析为 AST,然后通过@babel/traverse 遍历 AST,找到所有的中文文案,然后将其替换为翻译函数。

由于中文文案可能存在模板字符串中的情况,所以需要同时处理 StringLiteral 和 TemplateElement 两种类型的节点。

考虑到后续还要合并相同的文案,所以这里不直接生成 key 来替换,只是提取出来中文文案,同时生成一个替换函数,用于生成替换后的文案。 代码如下:

import { parse as babelParse } from '@babel/parser'
import traverse from '@babel/traverse'

interface CodeMark {
  value: string[]
  start: number
  end: number
  getReplaceStr: (ids: string[]) => string
}

async function getZhTextFromJsCode(code: string) {
 const ast = babelParse(code, {
   sourceType: 'module',
 })
 const zhTextList: CodeMark[] = []
 traverse(ast, {
   StringLiteral: (path) => {
     const { value } = path.node
     const zhList = matchZh(value)

     if (zhList.length === 0) return

     zhTextList.push({
       value: zhList.map((item) => item.text),
       start: path.node.start || 0,
       end: path.node.end || 0,
       getReplaceStr: createGetReplaceStr(value, zhList),
     })
   },
   TemplateElement: (path) => {
     const { value } = path.node

     const zhList = matchZh(value.raw)

     if (zhList.length === 0) return

     zhTextList.push({
       value: zhList.map((item) => item.text),
       start: path.node.start || 0,
       end: path.node.end || 0,
       getReplaceStr: createGetReplaceStr(value.raw, zhList, true),
     })
   },
 })

 return zhTextList
}

function matchZh(text: string) {
 const res: { text: string; start: number }[] = []
 let startIndex = 0

 while (true) {
   const matchResult = text.match(/[\u4e00-\u9fa5]+/)
   if (!matchResult) break

   res.push({ text: matchResult[0], start: (matchResult.index || 0) + startIndex })
   text = text.slice((matchResult.index || 0) + matchResult[0].length)
   startIndex += (matchResult.index || 0) + matchResult[0].length
 }

 return res
}

function createGetReplaceStr(value: string, zhList: { text: string; start: number }[], isInTemplate: boolean = false) {
 return (ids: string[]) => {
   let replaceString = isInTemplate ? '' : '`'

   for (let i = 0; i < zhList.length; i++) {
     if (i === 0) {
       replaceString += value.slice(0, zhList[0].start).replace(/`/g, '\\`')
       if (this.isEnChart(replaceString, true)) {
         replaceString += ' '
       }
     }

     const afterStart = zhList[i].start + zhList[i].text.length
     const afterEnd = zhList[i + 1]?.start ?? value.length
     let afterStr = value.slice(afterStart, afterEnd).replace(/`/g, '\\`')
     if (this.isEnChart(afterStr)) {
       afterStr = ` ${afterStr}`
     }
     replaceString += `\${${this.options.translateFunctionName}("${ids[i]}")}${afterStr}`
   }

   return replaceString + (isInTemplate ? '' : '`')
 }
}
2. 翻译中文文案

将提取出来的中文文案合并相同的内容,获得一个中文的列表,然后通过 AI 将其翻译成其他语言。

考虑到 AI 返回的结果可能不对,所以这里需要对 AI 返回的结果进行校验,若不符合要求则丢弃。

代码如下:

import OpenAI from 'openAi'

async function translateToRequest(
  textList: string[],
  targetLanguageList: string[],
  options: { aiApiKey: string, aiHost: string, aiModel: string }
): Promise<Record<string, string[]>> {
  const prompt = `将以下列表翻译成: ${targetLanguageList.join(',')}, 结果以json返回, 例如: { "zh": ["你好", "世界"], "en": ["hello", "world"] }
${JSON.stringify(textList)}`

  const openAi = new OpenAI({
    apiKey: options.aiApiKey,
    baseURL: options.aiHost,
  })
  const res = await openAi.chat.completions.create({
    model: options.aiModel,
    messages: [{ role: 'user', content: prompt }],
  })

  const jsonList = matchJson(res.choices[0].message.content || '')
  if (jsonList.length === 0) {
    throw new Error('翻译失败')
  }

  const translateItem = jsonList.find((item) => isMultLangJson(item, targetLanguageList, textList.length))
  if (!translateItem) {
    throw new Error('未获取到符合条件的翻译结果')
  }

  return translateItem
}

// 从AI响应的结果中提取json
function matchJson(text: string) {
  const reg = /```json\n([\s\S]*?)\n```/g

  const res: any[] = []
  const matchRes = text.match(reg)

  matchRes?.forEach((item) => {
    try {
      res.push(JSON.parse(item.substring(8, item.length - 4)))
    } catch (error) {}
  })

  return res
}

// 判断json是否包含所有的语言,且每个语言是否都一一对应
function isMultLangJson(value: any, langs: string[], count: number) {
  if (Object.prototype.toString.call(value) !== '[object Object]') return false

  for (const lang of langs) {
    if (!value[lang] || !Array.isArray(value[lang]) || value[lang].length !== count) return false

    for (const item of value[lang]) {
      if (typeof item !== 'string') return false
    }
  }

  return true
}
3. 生成文案对应的 key

这个步骤比较简单,由于文案存在一个数组中,所以可以直接以数组的索引作为 key,并按语言分类生成 json 文件即可

4. 替换代码

由于第一步中已经保存了需要翻译的文案以及当前文案替换的起始位置,结束为止,以及生成替换后代码的函数,所以这里只需要遍历所有的代码通过中文文案找到对应的 key(第三步中 key 就是数组下标),然后替换即可。

5. 插入翻译函数

首先需要移除 index.html 中的 script 标签(这个标签是入口 js,需要在翻译文案加载完成之后再插入执行,否则可能出现部分代码执行时,翻译文件未加载完成,导致找不到对应的文案),然后插入一段翻译函数,并加载翻译文件,再将翻译函数挂在到 window 上,最后再插入 script 标签来加载入口 js。

总结

以上只是一个大概思路,实际在实现时考虑到一些现实的情况,不可能每次打包都去调用一次 AI 翻译,所以我们将翻译后的文案缓存起来,每次打包后先去对比一下缓存中是否已经有了,若没有才通过 AI 翻译去翻译新的文案。同时由于翻译内容太多大模型可能挂掉,所以我们每次上传翻译都是分片上传,十几个词语翻译一次。通过以上步骤,我们可以实现零侵入的国际化,并且支持了六十多种语言。

这里封装了一个 vite 插件,可以直接使用,该插件仅支持将中文翻译为其他语言,具体使用方式可以查看文档