如何用babel优雅地把某字符串改成变量名?

Posted on 2024-03-18 |    

需求背景

近期产品有个需求,需要页面中所有包含客户的展示,根据系统版本进行展示,
销售版本客户称为”客户“,运营版本客户称为”用户“,原来代码里只是常量字符串,现在需要根据版本进行替换。需求很简单,创建一个全局变量,然后对源码进行一一替换即可。
一搜代码发现, 替换的工作量巨大,有近2000个地方包含了”客户“二字,手动替换肯定是不现实的。
image.png

拆解目标

拆解下我们的目标, 我们需要在包含有效(注释中不替换)”客户“的代码文件中引入全局变量,

1
2
// CUSTOMER_LABEL 会根据版本设置为'客户'或者‘用户’
import {CUSTOMER_LABEL} from '@/constants/version';

情况1:如果是字符串中包含”客户“,需要将整个字符串转成模版字符串再引入变量,例如

1
2
3
message.success('保存客户成功')
//转化成
message.success(`保存${CUSTOMER_LABEL}成功`)

情况2:如果是React组件中的变量名,则需要做如下处理 :

1
2
3
<Modal title='客户字段'></Modal>
//转换成
<Modal title={`${CUSTOMER_LABEL}字段`}></Modal>

情况3: 该字符串本身就已经是模版字符串,则直接替换即可

1
2
3
message.success(`${type}客户成功`);
// 转换成
message.success(`${type}${CUSTOMER_LABEL}成功`)

情况4:如果是ReactNode中包含字符串,则使用变量即可, 例如

1
2
3
<span>客户名称</span>
// 转换成
<span>{CUSTOMER_LABEL}名称

方案思考

全局替换

优点:简单方便
缺点:不准确,无法全部实现如上功能。还是需要额外处理一些事情,比如普通字符串需要改成模版字符串,ReactNode中的情况和字符串本身也略有不同;
直接全局替换不可行;

正则表达

写正则表达可以解决一部分问题,但是例如注释中的客户我们不需要替换,会增加一些复杂度,如何判断是是在普通字符串中还是ReactNode节点中可能情况会考虑不周(反正正则我弱我就不用~)

使用babel对源码进行操作

优点就是优雅,能够完美的解决所有情况,也不会误替换注释中的字符串,缺点就是对于不够熟悉babel的同学来说开发时间长一点,但是好在本人有babel实践经验,开发个小工具替换下也比较快。

babel API简单介绍

babel的编译流程

整个流程分成三个阶段,parse、transform、generate。
parse阶段将源代码解析成抽象语法树(AST),这个过程包括词法分析、语法分析。
transform 阶段是对 parse 生成的 AST 的处理,会进行 AST 的遍历,遍历的过程中处理到不同的 AST 节点会调用注册的相应的 visitor 函数,visitor 函数里可以对 AST 节点进行增删改,返回新的 AST,这样就完成了对源码的修改
generate阶段会把修改后的AST再转化为修改后的源代码
image.png

Babel的API

  • parse 阶段:@babel/parser,负责把源码转成 AST
1
2
3
4
5
require('@babel/parser').parse('code', {

sourceType: 'module',
plugins: ['jsx', 'typescript'],
});

其中sourceType为 指定是否支持解析模块语法
- module:解析 es module 语法
- script:不解析 es module 语法
- unambiguous:根据内容是否有 import 和 export 来自动设置 module 还是 script

  • transform阶段:
    • @babel/traverse:遍历AST,调用visitor函数修改AST,其中遍历过程中path变量是遍历过程中的路径,会保留上下文信息,有很多属性和方法,比如:
      • path.node 指向当前 AST 节点;
      • path.get、path.set 获取和设置当前节点属性的 path;
      • path.parent 指向父级 AST 节点;
      • path.getSibling、 path.getNextSibling、 path.getPrevSibling 获取兄弟节点;
      • path.find 从当前节点向上查找节点;
      • path.insertBefore,path.insertAfter 插入节点;path.replaceWith,path.replaceWithMultiple,replaceWithSourceString 替换节点;
      • path.remove 删除节点
    • @babel/types:在修改AST的时候涉及对AST节点的判断,以及创建AST的时候使用,例如
1
2
// 创建if语句
t.ifstatement(test, consequent, alternate);
1
2
3
4
5
// 判断节点是否是 IfStatement 就可以调用 islfStatement 或者 assertlfStatement, isXxx 会返回 boolean 表示结果,而 assertXxx 则会在类型不一致时抛异常。
t.isIfstatement(node, opts);

// opts 可以指定一些属性是什么值,增加更多限制条件,做更精确的判断。
t.isIdentifier(node, { name: "paths" })
  • 替换代码:
    • @babel/core,babel的核心,可以根据我们的配置文件以及使用的babel插件替换代码

编写babel插件常用到的AST查看的网址为:AST explorer,可以清晰的看到每行代码对应的一个AST的数据以及节点类型等,在左下角也可以直接里面编写插件进行替换,查看编写是否正确。

实战

AST的生成及代码反生成部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
const parser = require('@babel/parser');
const { transformFromAstSync } = require('@babel/core');
// 编写的插件
const plugin = require('./plugin');

function transformCode(source) {
// 使用@babel/parser解析代码
const ast = parser.parse(source, {
sourceType: 'module',
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'],
plugins: [
// react 项目需要引入相关插件,babel自带
'jsx', // Enable JSX syntax
'classProperties', // Enable class properties syntax
'typescript', // Enable TypeScript syntax
'moduleStringNames', // Enable module string names syntax
'exportNamespaceFrom', // Enable export namespace from syntax
'exportDefaultFrom', // Enable export default from syntax
'dynamicImport', // Enable dynamic import syntax
'asyncGenerators', // Enable async generators syntax
['decorators', { decoratorsBeforeExport: !1 }], // Enable decorators syntax
'asyncDoExpressions', // Enable async do expressions syntax
'doExpressions', // Enable do expressions syntax
'functionBind', // Enable function bind syntax
'throwExpressions', // Enable throw expressions syntax
],
});

const { code } = transformFromAstSync(ast, source, {
plugins: [[plugin, {}]],
});

return code;
}

plugin.js中的一些变量设定:

1
2
3
const { types: t } = babel;
const stringValue = '客户';
const regex = new RegExp(stringValue, 'g');

在转化插件部分,一共有4个部分:

第一部分:引入公共变量

1
import { CUSTOMER_LABEL } from "@/constants/version";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// plugin.js
Program(path) {
let lastImportIndex = -1;
let hasImport = false;
path.traverse({
ImportDeclaration(path) {
// 判断是否已经引入文件
lastImportIndex = path.key;
if (path?.node?.source.value === '@/constants/version') {
hasImport = true;
}
},
});
if (hasImport) return;
// 构建引入模版
const importStatement = t.importDeclaration(
[t.importSpecifier(t.identifier('CUSTOMER_LABEL'), t.identifier('CUSTOMER_LABEL'))],
t.stringLiteral('@/constants/version'),
);
if (lastImportIndex > -1) {
// 在最后一个 import 语句后面插入新的 import 语句
path.get('body')[lastImportIndex].insertAfter(importStatement);
} else {
path.unshiftContainer('body', importStatement);
}
},

第二部分:对字符串进行转化,其中字符串在AST中的节点为StringLiteral,我们只要遍历这类节点即可。
而在字符串中有两种情况,一个是普通字符串,还有一类是在React组件属性中的字符串,分别对应上面所写的情况1与情况2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
StringLiteral(path) {
const { node } = path;
// 判断字符串中是否包含"客户"
if (node.value.includes(stringValue)) {
// 判断是否是JSX属性值
if (t.isJSXAttribute(path.parent)) {
const elementList = node.value.split(stringValue);
const newElementList = elementList.map((item, index) =>
index === elementList.length - 1
? t.templateElement({ raw: item }, true)
: t.templateElement({ raw: item }),
);
const count = (node.value.match(regex) || []).length;
// 转换为模版字符串
const templateLiteral = t.templateLiteral(
newElementList,
new Array(count).fill(t.identifier('CUSTOMER_LABEL')),
);
const jsxExpressionContainer = t.jsxExpressionContainer(templateLiteral);
// 进行AST节点替换
path.replaceWith(jsxExpressionContainer);
} else {
// 普通字符串的处理
const elementList = node.value.split(stringValue);
const newElementList = elementList.map((item, index) =>
index === elementList.length - 1
? t.templateElement({ raw: item }, true)
: t.templateElement({ raw: item }),
);
const count = (node.value.match(regex) || []).length;
const templateLiteral = t.templateLiteral(
newElementList,
new Array(count).fill(t.identifier('CUSTOMER_LABEL')),
);
path.replaceWith(templateLiteral);
}
}
},

第三部分:针对模版字符串类型进行转换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TemplateLiteral(path) {
const { node } = path;
// 遍历模板字符串中的所有模板元素
for (let i = 0; i < node.quasis.length; i++) {
const templateElement = node.quasis[i];
const { value } = templateElement;

// 判断模板元素中是否包含"客户"
if (value.raw.includes(stringValue)) {
// 将模板元素中的"客户"替换为`${CUSTOMER_LABEL}`
const updatedValue = value.raw.replace(regex, '${CUSTOMER_LABEL}');
// 更新模板元素的值
templateElement.value.raw = updatedValue;
templateElement.value.cooked = updatedValue;
}
}
},

第四部分:针对React Node中包含的文字进行转换,对应上述情况4,ReactNode包含的文字对应的AST节点为JSXText类型,转换成一个React Node的表达式AST对应的是JSXExpressionContainer,则可通过构建这个类型,然后再进行替换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const generator = require('@babel/generator').default;

JSXElement(path) {
path.traverse({
JSXText(textPath) {
const textValue = textPath.node.value.trim();
if (textValue.includes(stringValue)) {
const variable = t.jsxExpressionContainer(t.identifier('CUSTOMER_LABEL'));

const newText = textValue.replace(stringValue, `${generator(variable).code}`);
textPath.replaceWith(t.jsxText(newText));
}
},
});
},

完整代码已上传github,方便查看。

总结

1、Babel插件编写的时候需要注意枚举所有情况,一开始没有注意到React的组件属性这种类型,在编写过程中不断调整即可。
2、在日常业务开发中,发现可以提升效率的点,将人工操作实现自动化、工具化。
3、此类替换思维还可以用于例如国际化场景中等。