国际化实践
最近公司有一个开发了一年多的 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 要对着这里面的文档来,会用以前学编程语言的一些概念(表达式,声明语句等等),我也是模模糊糊有点懵,最后也算是完成了任务,节省了大量的人工替换代码的时间