背景
当前需要针对现有的项目做一个多语言国际化的处理,当前项目已经迭代很久,并且之前一直没有预埋多语言的处理
使用什么国际化的库
目前我们直接使用 i8next 这个成熟的多语言库,具体文档可以查看这里:www.i18next.com/overview/ap…
在组件模块的具体接入方式,我们可以如下:
把 i18n 的实例方法挂在在 window 上,入口文件引入 i18n 模块,其他渲染组件直接使用 window.t,为了保持现有代码的可读性,我们使用中文作为翻译的 key
// i18n 模块单例定义
...
i18n.init(options);
window.t = i18n.t;
...
// 入口模块
import '../../utils/i18n';
// 组件模块
const { t } = window;
...
<div>t('测验详情')</div>
...
因为我们的代码已经是迭代时间比较久,体量比较庞大,手工解决修改需要消耗巨多人力,因此手工解决不现实,我们需要用脚本的方式解决。以上代码如果我们脚本生成的方式,我们要解决的问题是
- 问题一:如何批量在入口文件添加 i18n 模块的引用
- 问题二:如何在组件模块添加 window 的解构赋值 t
- 问题三:如何把组件内的中文:'文案' 转化为 t('文案')
工具:语法树(AST)分析
这里需要一个能对代码进行语法树分析,并且修改语法树,重新生成代码的工具,这里使用的是 jscodeshift 这个库。
其中提供了一个语法树的可视化平台:astexplorer.net/ 只要把代码放上去就可以直观查看当前语法树结构,如下图所示
1、解决第一个问题:如何批量在入口文件添加 i18n 模块的引用:
首先提供两个工具方法,可以实现【源码 - ast】互相转化,代码如下
const fs = require('fs-extra');
const jscodeshift = require('jscodeshift');
const j = jscodeshift.withParser('tsx');
/**
* 源码 转 ast 语法树
* @param {string} entry
* @returns ast 语法树
*/
const getAstFromSource = (entry) => {
let source = fs.readFileSync(entry, { encoding: 'utf8' });
const ast = j(source);
return ast;
};
/**
* ast 转源码
* @param {ast} ast
* @returns source
*/
const getSourceFromAst = (ast) => {
return ast.toSource({ lineTerminator: '\n' });
};
工具类:遍历文件系统,返回路径名
const fg = require('fast-glob');
/**
* 获取路径匹配的实际路径数组
* @param {string[]} entry
* @returns
*/
const getEntryPath = (entry) => {
const entries = fg.sync(entry, {
dot: true,
cwd: process.cwd(),
});
return entries;
};
批量在文件中添加 i18n 引用
/**
* 给文件批量添加 import 语句
* @param {Object} params 参数
* @param {string[]} params.path 目标文件
* @param {string} params.importValue 需要 import 的语句
*/
const modifyIndexEntryFile = (params) => {
const { path, importValue } = params;
const entries = getEntryPath(path);
entries.forEach((entry) => {
const originData = getAstFromSource(entry); // 获取 ast
let hasI18n = false; // 是否已经引入过 i18n 模块
originData.find(j.ImportDeclaration).forEach((path) => {
if (_.get(path, 'value.source.value') === importValue) {
hasI18n = true;
}
});
if (!hasI18n) {
//如果没有引入 i18n,则添加进去
let newData = getSourceFromAst(originData); // 获取源码
// 可以直接在源码最前面拼接引用
newData = `import '${importValue}';\n` + newData;
fs.writeFileSync(entry, newData);
}
});
};
方法调用
modifyIndexEntryFile({
path: ['src/renderer/*/index.tsx'],
importValue: 'renderer/common/helper/i18n',
});
2、解决第二个问题:如何在组件模块添加 window 的解构赋值 t
这里的又分为三种情况
- 模块中已经有 const { t } = window; 了,则此时不需要做处理
- 模块中已经有了 const { a } = window; 此时需要转化为 const { a, t } = window;
- 模块中没有 window 解构赋值,则此时需要在模块添加 const { t } = window;
判断模块中是否有 window 解构赋值
const _ = require('lodash');
const jscodeshift = require('jscodeshift');
const j = jscodeshift.withParser('tsx');
const originData = getAstFromSource(entry);
originData.find(j.VariableDeclaration).forEach((path) => {
const initName = _.get(path, 'value.declarations[0].init.name');
if (initName === 'window') {
// 包含 window 解构赋值
} else {
// 不包含 window 解构赋值
}
});
判断结构解析中,是否有包含 t 的解析
const properties = _.get(path, 'value.declarations[0].id.properties');
if (
_.find(properties, (item) => {
return _.get(item, 'value.name') === 't';
})
) {
// 已经有 window 解构赋值 t 了, 此时不需要再做处理
} else {
// 此时已经存在 window 解构赋值,但是没有赋值 t,此时需要添加变量 t 的取值
}
添加变量 t 的取值,此时能实现 const { a } = window; 转化为 const { a, t } = window;
properties.push({
type: 'ObjectProperty',
key: {
type: 'Identifier',
name: 't',
optional: false,
typeAnnotation: null,
},
computed: false,
method: false,
shorthand: true,
value: {
type: 'Identifier',
range: undefined,
extra: undefined,
name: 't',
optional: false,
typeAnnotation: null,
},
extra: { shorthand: true },
accessibility: null,
});
添加 const { t } = window;
const insertCode = `const { ${'t'} } = ${'window'};`;
const formRef = j(insertCode).nodes()[0].program.body[0];
const importList = originData.find(j.ImportDeclaration).__paths;
if (importList.length > 0) {
// 如果此时有 import 语句,则直接放在 import 语句的后面
importList[importList.length - 1].insertAfter(formRef);
} else {
// 没有 import 语句,则直接添加到文件的最前面
j(originData.find(j.Declaration).at(0).get()).insertBefore(insertCode);
}
3、解决第三个问题:如何把组件内的中文:'文案' 转化为 t('文案')
对于简单的字面量类型,我们可以如下实现,因为本项目基于 React、因此需要区分 jsx 跟 普通代码
const _ = require('lodash');
originData
.find(
j.Literal, // 获取字面量
(p) => /[\u4e00-\u9fa5]/.test(p.value) && !/\.(png|jpg|gif|svg)(\?.*)?/.test(p.value) // 排除中文命名的图片
)
.forEach((path) => {
const value = _.trim(path.node.raw || path.node.value);
if (path.node.type === 'JSXText') {
// 如果是 jsx 类型,则需要花括号
stringTemplate = `{${'t'}('${value}')}`;
} else if (path.parentPath.node.type === 'JSXAttribute') {
// 如果是 jsx 类型,则需要花括号
stringTemplate = `{${'t'}('${value}')}`;
} else {
// 普通类型
stringTemplate = `${'t'}('${value}')`;
}
});
除了字面量类型外,还有模板字符串类型, 模板字符串类型相对复杂一点
originData.find(j.TemplateLiteral).forEach((path) => {
let stringTemplate = '';
path.node.quasis.forEach((item, index) => {
if (index >= path.node.expressions.length) {
if (/[\u4e00-\u9fa5]/.test(item.value.raw)) {
stringTemplate += '${t("' + _.trim(item.value.raw) + '")}';
newValueList.push(_.trim(item.value.raw));
} else {
stringTemplate += item.value.raw;
}
} else {
const expressionString = j(path.node.expressions[index]).toSource();
if (/[\u4e00-\u9fa5]/.test(item.value.raw)) {
stringTemplate =
stringTemplate +
'${t("' +
_.trim(item.value.raw) +
'")}$' +
`{${expressionString}}`;
newValueList.push(_.trim(item.value.raw));
} else {
stringTemplate = stringTemplate + item.value.raw + '$' + `{${expressionString}}`;
}
}
});
stringTemplate = '`' + stringTemplate + '`';
j(path).replaceWith((p) => {
p.node.raw = stringTemplate;
return p.node.raw;
});
});
以上就是通过 语法树分析,修改现有代码达到多语言 翻译函数的引入与替换效果,当然,上述只是简单实现,具体实现,还需要考虑到 如何避免 翻译函数嵌套、排除某些翻译之类的。当然目前还有以下问题:
- 替换过后代码不符合原项目的的 eslint 和 prettier 检查规则
- 对于已存在在中文,是否能实现机器翻译,非人工翻译
以上两个问题会在下一篇解决