章
目
录
今天,我们来深入探讨如何基于抽象语法树(AST)实现国际化文本提取,在阅读前,建议读者先对babel有一定的了解,其架构主要涵盖工具层(像@babeltypes、@babeltemplate等 )、核心部分、生态系统等,其中语法解析器支持多种语法,如ESNext、Typescript、JSX等。
一、确定项目中的中文范围
在开始提取国际化文本前,首先要明确项目里可能出现中文的场景。在JavaScript代码中,常见的情况有普通字符串定义、模版字符串使用、在React组件的DOM子节点以及props属性中。例如:
const a = '霜序';
const b = `霜序`;
const c = `${isBoolean} ? "霜序" : "FBB"`;
const obj = { a: '霜序' };
// enum Status {
// Todo = "未完成",
// Complete = "完成"
// }
// enum Status {
// "未完成",
// "完成"
// }
const dom = <div>霜序</div>;
const dom1 = <Customer name="霜序" />;
从AST的角度来看,不同的中文存在形式对应不同的节点类型。
- StringLiteral节点:普通字符串在AST中对应的节点为
StringLiteral
。当我们要进行国际化文本提取时,需要遍历所有的StringLiteral
节点,然后将其替换为I18N.key
这样的节点形式,以便后续进行国际化处理。例如,对于const a = '霜序';
,在AST中会表示为:
{
"type": "StringLiteral",
"start": 10,
"end": 14,
"extra": {
"rawValue": "霜序",
"raw": "'霜序'"
},
"value": "霜序"
}
- TemplateLiteral节点:模版字符串对应的AST节点是
TemplateLiteral
,它的情况相对复杂一些,因为其中可能包含变量。在TemplateLiteral
节点中,expressions
字段表示变量,quasis
字段表示字符串部分。比如const b =
${finalRoles}(质量项目:${projects});
,其AST表示如下:
{
"type": "TemplateLiteral",
"start": 10,
"end": 43,
"expressions": [
{
"type": "Identifier",
"start": 13,
"end": 23,
"name": "finalRoles"
},
{
"type": "Identifier",
"start": 32,
"end": 40,
"name": "projects"
}
],
"quasis": [
{
"type": "TemplateElement",
"start": 11,
"end": 11,
"value": {
"raw": "",
"cooked": ""
}
},
{
"type": "TemplateElement",
"start": 24,
"end": 30,
"value": {
"raw": "(质量项目:",
"cooked": "(质量项目:"
}
},
{
"type": "TemplateElement",
"start": 41,
"end": 42,
"value": {
"raw": ")",
"cooked": ")"
}
}
]
}
如果直接遍历TemplateElement
节点,只提取中文而不管变量,会导致翻译时上下文缺失,出现翻译不准确的问题。理想的处理方式是将其处理成{val1}(质量项目:{val2})
这种形式,并把对应的val1
和val2
传入,例如:
I18N.get(I18N.K, {
val1: finalRoles,
val2: projects,
});
- JSXText节点:在React的JSX中,文本内容对应的AST节点为
JSXText
。我们需要遍历JSXElement
节点,然后在其children
中找到JSXText
节点来处理中文文本。比如:
{
"type": "JSXElement",
"start": 12,
"end": 25,
"children": [
{
"type": "JSXText",
"start": 17,
"end": 19,
"extra": {
"rawValue": "霜序",
"raw": "霜序"
},
"value": "霜序"
}
]
}
- JSXAttribute节点:当中文出现在JSX的属性中时,对应的AST节点是
JSXAttribute
,而中文实际存在的节点还是StringLiteral
。不过在处理时需要特殊对待,因为对于JSX中的数据,我们需要用{}
包裹,而不是直接进行文本替换。示例如下:
{
"type": "JSXOpeningElement",
"start": 13,
"end": 35,
"name": {
"type": "JSXIdentifier",
"start": 14,
"end": 22,
"name": "Customer"
},
"attributes": [
{
"type": "JSXAttribute",
"start": 23,
"end": 32,
"name": {
"type": "JSXIdentifier",
"start": 23,
"end": 27,
"name": "name"
},
"value": {
"type": "StringLiteral",
"start": 28,
"end": 32,
"extra": {
"rawValue": "霜序",
"raw": "\"霜序\""
},
"value": "霜序"
}
}
],
"selfClosing": true
}
二、利用Babel进行文本提取处理
明确了中文在AST中的节点类型后,接下来就可以借助Babel工具来实现国际化文本的提取。Babel主要涉及@babel/parser
、@babel/traverse
、@babel/generate
等模块。
- 使用@babel/parser转译源代码为AST:利用
@babel/parser
可以将源代码解析成AST,代码如下:
const plugins: ParserOptions['plugins'] = ['decorators-legacy', 'typescript'];
if (fileName.endsWith('text') || fileName.endsWith('text')) {
plugins.push('text');
}
const ast = parse(sourceCode, {
sourceType: 'module',
plugins,
});
在这段代码中,根据文件类型选择合适的插件,然后将源代码解析为AST,为后续处理做准备。
- 使用@babel/traverse处理AST节点:
@babel/traverse
用于遍历和修改AST节点。针对前面提到的不同节点类型,我们进行如下处理:
babelTraverse(ast, {
StringLiteral(path) {
const { node } = path;
const { value } = node;
if (
!value.match(DOUBLE_BYTE_REGEX) ||
(path.parentPath.node.type === 'CallExpression' &&
path.parentPath.toString().includes('console'))
) {
return;
}
path.replaceWithMultiple(template.ast(`I18N.${key}`));
},
TemplateLiteral(path) {
const { node } = path;
const { start, end } = node;
if (!start ||!end) return;
let templateContent = sourceCode.slice(start + 1, end - 1);
if (
!templateContent.match(DOUBLE_BYTE_REGEX) ||
(path.parentPath.node.type === 'CallExpression' &&
path.parentPath.toString().includes('console')) ||
path.parentPath.node.type === 'TaggedTemplateExpression'
) {
return;
}
if (!node.expressions.length) {
path.replaceWithMultiple(template.ast(`I18N.${key}`));
path.skip();
return;
}
const expressions = node.expressions.map((expression) => {
const { start, end } = expression;
if (!start ||!end) return;
return sourceCode.slice(start, end);
});
const kvPair = expressions.map((expression, index) => {
templateContent = templateContent.replace(
`${${expression}}`,
`{val${index + 1}}`,
);
return `val${index + 1}: ${expression}`;
});
path.replaceWithMultiple(
template.ast(`I18N.get(I18N.${key},{${kvPair.join(',\n')}})`),
);
},
JSXElement(path) {
const children = path.node.children;
const newChild = children.map((child) => {
if (babelTypes.isJSXText(child)) {
const { value } = child;
if (value.match(DOUBLE_BYTE_REGEX)) {
const newExpression = babelTypes.jsxExpressionContainer(
babelTypes.identifier(`I18N.${key}`),
);
return newExpression;
}
}
return child;
});
path.node.children = newChild;
},
JSXAttribute(path) {
const { node } = path;
if (
babelTypes.isStringLiteral(node.value) &&
node.value.value.match(DOUBLE_BYTE_REGEX)
) {
const expression = babelTypes.jsxExpressionContainer(
babelTypes.memberExpression(
babelTypes.identifier('I18N'),
babelTypes.identifier(`${key}`),
),
);
node.value = expression;
}
},
});
在处理TemplateLiteral
节点时,如果存在变量,需要通过截取的方式获取模版字符串templateContent
,然后遍历expressions
,用{val(index)}
替换掉templateContent
中的变量,最后使用I18N.get
的方式来获取对应的值。不过,TemplateLiteral
节点如果存在嵌套情况,会出现处理问题,这是因为babel
不会自动递归处理其嵌套模板。
- 在AST顶部插入引入语句:处理完AST节点后,我们需要统一引入
I18N
变量。在文件的AST顶部的import
语句后插入相关的importStatement
,代码如下:
Program: {
exit(path) {
const importStatement = projectConfig.importStatement;
const result = importStatement
.replace(/^import\s+|\s+from\s+/g, ',')
.split(',')
.filter(Boolean);
// 判断当前的文件中是否存在importStatement语句
const existingImport = path.node.body.find((node) => {
return (
babelTypes.isImportDeclaration(node) &&
node.source.value === result[1]
);
});
if (!existingImport) {
const importDeclaration = babelTypes.importDeclaration(
[
babelTypes.importDefaultSpecifier(
babelTypes.identifier(result[0]),
),
],
babelTypes.stringLiteral(result[1]),
);
path.node.body.unshift(importDeclaration);
}
},
}
- 将处理后的AST转为代码:使用
@babel/generate
将处理后的AST转换回代码,代码如下:
const { code } = generate(ast, {
retainLines: true,
comments: true,
});
三、其他相关处理
(一)动态生成key
为了确保每个中文文本在国际化处理中有唯一的标识,我们需要动态生成key
。这里的生成方式类似excel
列名的生成规则,代码如下:
export const getSortKey = (n: number, extractMap = {}): string => {
let label = '';
let num = n;
while (num > 0) {
num--;
label = String.fromCharCode((num % 26) + 65) + label;
num = Math.floor(num / 26);
}
const key = `${label}`;
if (_.get(extractMap, key)) {
return getSortKey(n + 1, extractMap);
}
return key;
};
每个文件的key
前缀则是根据文件路径生成的,且不包含extractDir
之前的内容,具体实现如下:
export const getFileKey = (filePath: string) => {
const extractDir = getProjectConfig().extractDir;
const basePath = path.resolve(process.cwd(), extractDir);
const relativePath = path.relative(basePath, filePath);
const names = slash(relativePath).split('/');
const fileName = _.last(names) as any;
let fileKey = fileName.split('.').slice(0, -1).join('.');
const dir = names.slice(0, -1).join('.');
if (dir) fileKey = names.slice(0, -1).concat(fileKey).join('.');
return fileKey.replace(/-/g, '_');
};
(二)脚手架命令
为了方便操作,我们提供了i18n-extract-cli
脚手架命令,目前支持以下几种操作:
- 初始化配置文件:执行
npx i18n-extract-cli init
,会生成一份i18n.config.json
配置文件,内容如下:
{
"localeDir": "locales",
"extractDir": "./",
"importStatement": "import I18N from @/utils/i18n",
"excludeFile": [],
"excludeDir": []
}
- 提取中文文本:运行
npx i18n-extract-cli extract
,可以将extractDir
目录下的中文文本提取到localeDir/zh-CN
中。 - 检查提取情况:使用
npx i18n-extract-cli extract:check
命令,能检查extractDir
文件夹中的中文是否提取完全,需要注意的是,console
中的中文也会被检查。 - 清理未使用的国际化文案:执行
npx i18n-extract-cli extract:clear
,可以清理extractDir
尚未使用的国际化文案。但要注意,该脚本是按每个文件路径作为key
来判断当前文件中的sortKey
是否使用,所以必须保证每个文件中使用的key
为fileKey + sortKey
,否则脚本会失效。
通过上述基于AST的国际化文本提取方法,配合Babel工具以及相关的脚手架命令,开发者能够高效地实现项目的国际化文本提取工作。希望本文能为大家在项目国际化开发过程中提供帮助。