基于AST实现国际化文本提取:原理、实践与工具详解
今天,我们来深入探讨如何基于抽象语法树(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(/^imports+|s+froms+/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工具以及相关的脚手架命令,开发者能够高效地实现项目的国际化文本提取工作。希望本文能为大家在项目国际化开发过程中提供帮助。