ZQC's Blog

国际化实践

最近公司有一个开发了一年多的 react 项目要做国际化的支持,项目中大量的中日文字符串要先提取出来找人翻译,再进行替换,这种工作既繁琐又重复,工作中很多这种类似的工作都是用正则匹配来替换的,但这次每个地方要替换的地方是要都有一个唯一对应的 id 的,因为国际化就是用一个唯一的 id 占位,在渲染页面的时候用那些 i18n 的组件去替换成对应的语言,我们这里选用的 i18n 方案是 react-i18next

那我们接到这个国际化的需求且后面会有一一对应 id 的替换,那用正则可能是不能够太满足我们的需求的,因为并不是在代码中的所有地方的字符串都需要替换,只有会渲染的地方的代码需要进行更改,那就很适合那种把字符串解析成 AST(抽象语法树)再遍历这个树进行增删改查的操作,最后再根据树生成代码就好了,这不就是我们的 babel 天天做的事嘛。

其实还在网上看到有写 babel 插件,然后实现在打包编译的时候来替换文本的,这样可能要打包出来多个结果出来,感觉不是很实用。我们的需求里还有支持产品可能临时修改了文案的情况,那我们国际化应该是作为一种配置在运行时被动态加载进去更好。

很多工作本来是可以在一个文件里全部完成的,但是考虑到中途可能有 bug 会被打断,就分开了好几步去完成,有什么不对的或者可以优化的地方可以提出来。

首先抽出所有的词并生成 Excel 让产品去翻译

// 递归读取文件夹得到所有的文件路径
function readFileList(dir, filesList = [], excludes = []) {
  const files = fs.readdirSync(dir)
  files.forEach((item, index) => {
    var fullPath = path.join(dir, item)
    if (excludes.some((e) => fullPath.indexOf(e) >= 0)) {
      return
    }
    const stat = fs.statSync(fullPath)
    if (stat.isDirectory()) {
      readFileList(path.join(dir, item), filesList, excludes) //递归读取文件
    } else {
      filesList.push(fullPath)
    }
  })
  return filesList
}

再遍历所有的路径,每个文件都是一样的处理流程

const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
 
const textMap = new Map()
 
filesList.forEach((fileUrl) => {
  const buffer = fs.readFileSync(fileUrl, { encoding: 'utf8' })
  const str = buffer.toString()
 
  const ast = parse(str, {
    sourceType: 'module',
    // errorRecovery: true,
    plugins: ['jsx', 'typescript'],
  })
 
  getTexts(ast, fileUrl)
})
 
function getTexts(ast, fileUrl) {
  traverse(ast, {
    enter(path) {
      const rawValue = path.node.value
      if (rawValue && typeof rawValue === 'string') {
        treatString(rawValue, fileUrl)
      }
    },
  })
}
function treatString(str, fileUrl) {
  const v = str.trim().replace(useLessReg, '')
  if (/[\u4e00-\u9fa5\u0800-\u4e00]/.test(v)) {
    console.log('中日文: ', v)
    if (v.includes('\n')) {
      v.split('\n').forEach((s) => treatString(s, fileUrl))
    } else if (textMap.has(fileUrl)) {
      textMap.get(fileUrl)[v] = ''
    } else {
      textMap.set(fileUrl, { [v]: '' })
    }
  } else {
    // console.log('非中日文: ', v)
  }
}

得到这个大的 Map 对象后,再生成对应的 json,我们项目里在排除了几个全是法律声明的文件后,还有 1000 多个地方是中日文要替换的,这要是人来做可是很花时间的。

const finalObj = {}
const entries = textMap.entries()
for ([uri, obj] of entries) {
  console.log('uri: ', uri)
  console.log('obj: ', obj)
  finalObj[uri] = obj
}
 
const finalStr = JSON.stringify(finalObj, null, 2)
fs.writeFileSync(path.resolve(__dirname, 'result.json'), finalStr)

得到这个 json 文件后,再怎么操作就是很自由的了,我这边是先用有道翻译翻译了一遍再输出到 Excel 让产品去处理的。

require('dotenv').config({ override: true })
const rawJson = require('./result.json')
const path = require('path')
const fs = require('fs')
const { Readable } = require('stream')
const crypto = require('crypto')
 
const trans = require('./trans.json') || {}
 
function truncate(q) {
  var len = q.length
  if (len <= 20) return q
  return q.substring(0, 10) + len + q.substring(len - 10, len)
}
var appKey = process.env.YOUDAO_APP_KEY
var key = process.env.YOUDAO_SECRET_KEY //注意:暴露appSecret,有被盗用造成损失的风险
// 请求有道接口拿到翻译结果,fetch 只有高版本的 node 支持
const getTranslate = (query) =>
  new Promise((resolve, reject) => {
    var hash = crypto.createHash('sha256')
 
    var salt = new Date().getTime()
    var curtime = Math.round(new Date().getTime() / 1000)
    // 多个query可以用\n连接  如 query='apple\norange\nbanana\npear'
    var from = 'ja'
    var to = 'en'
    var str1 = appKey + truncate(query) + salt + curtime + key
    var sign = hash.update(str1).digest('hex')
 
    fetch(
      `http://openapi.youdao.com/api?q=${encodeURIComponent(
        query
      )}&appKey=${appKey}&salt=${salt}&from=${from}&to=${to}&sign=${sign}&signType=v3&curtime=${curtime}&_=1668060573394`,
      {
        headers: {
          accept: '*/*',
          'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
        },
        referrerPolicy: 'strict-origin-when-cross-origin',
        body: null,
        method: 'GET',
      }
    )
      .then((res) => {
        return res.json().then((responseJson) => {
          // console.log(
          //   'responseJson.data.rawDictData.d_lemma.trans.translation: ',
          //   responseJson.data.rawDictData.d_lemma.trans.translation
          // )
          return responseJson.translation ? responseJson.translation[0] : ''
        })
      })
      .then(resolve)
      .catch(reject)
  })
 
function sleep(ms) {
  return (arg) =>
    new Promise((resolve) => {
      setTimeout(() => {
        resolve(arg)
      }, ms)
    })
}
 
let ones = true
const requestKey = (key) => {
  return getTranslate(key).then((tran) => {
    console.log(`original: ${key}, tran: ${tran}`)
    trans[key] = tran
    return tran
  })
}
 
// 串行请求链
const promiseChain = Object.keys(rawJson).reduce((p, fileUrl) => {
  const toObj = rawJson[fileUrl]
  return p.then(
    () =>
      Object.entries(toObj)
        .reduce((p, [key]) => {
          return p.then(() => {
            return trans[key]
              ? Promise.resolve() // 请求过的直接跳过
              : requestKey(key)
                  .then(sleep(1000)) // 调一次 api 起码等一秒
                  .catch((err) => {
                    console.log(`fail on key is ${key}`, err)
                    console.log('retry once')
                    return sleep(1000)().then(() => requestKey(key)) // 重试一次再失败也不管了
                  })
          })
        }, Promise.resolve())
        .finally(writeToFile) // 每翻译完一个文件的字符串就去写一遍,免得中间 api 报错我全都挂了又要重来
  )
}, Promise.resolve())
 
promiseChain.finally(() => {
  writeToFile()
})
function writeToFile() {
  const fileJsonWrite = fs.createWriteStream(
    path.resolve(__dirname, './trans.json'),
    {
      flags: 'w', // 覆盖不报错
    }
  )
  const strReadSteam = new Readable()
  strReadSteam.pipe(fileJsonWrite)
  const jsonString = JSON.stringify(trans, null, 2)
  strReadSteam.push(jsonString)
  strReadSteam.push(null)
}
 

其实上面这个 promiseChain 很有可能会有失败的情况(实际上只失败了一次,还是我对响应结果考虑不够充分),所以我要每次翻译完一个文件就去写一遍,并且失败后下次再打开还能直接从失败的文件再开始,全部完成后会得到一个只有原文到翻译的 json,再从这个 json 去把那个有对应每个文件的 json 填补上就行了,代码很简单

const transJson = require('./trans.json')
const path = require('path')
const rawJson = require('./result.json')
const fs = require('fs')
const { Readable } = require('stream')
 
let notFoundNum = 0
const transResult = Object.keys(rawJson).reduce((obj, key) => {
  obj[key] = Object.keys(rawJson[key]).reduce((obj, innerKey) => {
    if (!transJson[innerKey]) {
      console.log('innerKey: ', innerKey)
      notFoundNum++
    }
    obj[innerKey] = transJson[innerKey] || ''
    return obj
  }, {})
  return obj
}, {})
 
console.log('notFoundNum: ', notFoundNum)
 
function writeToFile() {
  const fileJsonWrite = fs.createWriteStream(
    path.resolve(__dirname, './transResult.json'),
    {
      flags: 'w',
    }
  )
  const strReadSteam = new Readable()
  strReadSteam.pipe(fileJsonWrite)
  const jsonString = JSON.stringify(transResult, null, 2)
  strReadSteam.push(jsonString)
  strReadSteam.push(null)
}
writeToFile()
 

下面是变成 excel,主要还是用 node-xlsx,组装一个两层的数组就行了

const path = require('path')
const rawJson = require('./transResult.json')
const xlsx = require('node-xlsx')
const fs = require('fs')
const steam = require('stream')
 
const data = []
let ones = true
 
Object.keys(rawJson).forEach((fileUrl) => {
  // if (!ones) return
  // ones = false
  const relativePath = path.relative(
    path.resolve(__dirname, process.env.RELATIVE_PATH),
    fileUrl
  )
  const result = relativePath
    .split(path.sep)
    .map((str) => str.split('.')[0].replace(/-/g, '_'))
  const key = result.join('_')
  const toObj = rawJson[fileUrl]
  const block = []
  Object.keys(toObj).forEach((str, index) => {
    block.push([
      `${key}${index + 1}`, // 用相对路径加数字生成了 id
      str, // 原文
      toObj[str], // 翻译结果
      'machine translate', // description
      'TRUE', // from UI
    ])
  })
  data.push(...block)
})
// console.log('data: ', data)
 
var buffer = xlsx.build([{ name: 'mySheetName', data: data }])
console.log('buffer: ', buffer)
const ws = fs.createWriteStream(path.resolve(__dirname, './result.xlsx'), {
  flags: 'w',
})
const readable = new steam.Readable({
  read() {},
})
readable.pipe(ws)
readable.push(buffer)
readable.push(null)

这么多步才到我们的 babel,哈哈,babel 这里是最费脑筋的,主要是网上的查到的资料比较少,我这里用的是比较笨的方法,每个任务都去遍历了一遍树,虽然笨且代码冗余且性能不高,但是思路看起来会清晰一点。代码比较多,且和前面的重复比较多,这里只贴如何遍历 AST 修改 AST 的部分

Program 其实就是相当于整个代码最外层了,整个遍历只有一次进入 Program,去查所有的可能出现中日文的地方,找到就开始跳过接下来所有的路径

// babel 的库用的都是 7.20+ 的
const { parse } = require('@babel/parser')
const traverse = require('@babel/traverse').default
const types = require('@babel/types')
const template = require('@babel/template').default
const generate = require('@babel/generator').default
 
// 准备的引入 AST
const importAST = template(`
import { useTranslation } from 'react-i18next'
`)()
 
// 主要是遍历 AST
  traverse(ast, {
    Program: (path, state) => {
      const checkState = { hasJapanese: false, parents: [] }
      path.traverse(
        {
          'StringLiteral|JSXText|TemplateLiteral': (path, state) => {
            if (state.hasJapanese) {
              path.skip()
              return
            }
            const rawValue = path.node?.value
            if (rawValue && typeof rawValue === 'string') {
              const v = path.node.value.trim().replace(useLessReg, '')
              if (/[\u4e00-\u9fa5\u0800-\u4e00]/.test(v)) {
                state.hasJapanese = true
              }
            }
          },
        },
        checkState
      )
      if (checkState.hasJapanese) {
        path.unshiftContainer('body', importAST)
      }
    },
    Function: (path, state) => {
      const checkState = {}
      path.traverse(
        {
          'StringLiteral|JSXText|TemplateLiteral': (path, state) => {
            const rawValue = path.node?.value
            if (rawValue && typeof rawValue === 'string') {
              const v = path.node.value.trim().replace(useLessReg, '').trim()
              if (/[\u4e00-\u9fa5\u0800-\u4e00]/.test(v)) {
                console.log('v: ', v)
                state.hasJapanese = true
                let replaceExpression = types.callExpression(
                  types.identifier('t'),
                  [types.stringLiteral(v)] // todo replace with id
                )
                console.log('replaceExpression: ', replaceExpression)
                let needJSXExpressionContainer = false
                path.findParent((p) => {
                  console.log(p.isJSXElement())
                  if (p.isJSXExpressionContainer()) {
                    return p
                  } else if (p.isJSXElement()) {
                    needJSXExpressionContainer = true
                    return p
                  } else if (p.isExpression()) {
                    return p
                  }
                })
                if (needJSXExpressionContainer) {
                  replaceExpression =
                    types.JSXExpressionContainer(replaceExpression)
                }
                path.replaceWith(replaceExpression)
                path.skip()
              }
            }
          },
        },
        checkState
      )
      if (checkState.hasJapanese) {
        path.traverse(
          {
            BlockStatement: (path, state) => {
              if (!state.hasAddStatement) {
                state.hasAddStatement = true
                path.unshiftContainer(
                  'body',
                  template(`
      const { t } = useTranslation()
    `)()
                )
              } else {
                path.skip()
              }
            },
          },
          {}
        )
      }
    },
  })

然后就是遍历 Function 类型,之所以之遍历 Function 类型是因为我们这个项目写的几乎全是 React hook,class 组件一只手都能数的过来。全局的变量声明中带有中日文那种语句也并不会很多,且后续需要集成到 React 的组件声明中,修改语法树我感觉比较复杂就不处理这种情况了,我这里思路就是在函数类型中遍历所有的可能中日文的地方,出现的话就引入 useTranslation,在每个找到中日文的地方改成一个表达式调用 t(...),并判断是否需要增加 jsx 中的 {} 来避免这个替换的文本变成了纯字符串

整个过程就是拿文件路径,拿到这个文件的中日文和全局唯一 id 的对应关系,然后解析成 AST,根据情况判断替换,看情况是否新增引入的语句。

其实挺简单的,难的是去了解 babel 如何改变代码(有个网址专门可以查看各种代码变成 AST 的样子),遍历 AST 的时候 path 对象上的方法有哪些(最后还是去翻了源码才大概了解了怎么解决我的需求),新建 AST 要对着这里面的文档来,会用以前学编程语言的一些概念(表达式,声明语句等等),我也是模模糊糊有点懵,最后也算是完成了任务,节省了大量的人工替换代码的时间