需求背景
近期产品有个需求,需要页面中所有包含客户的展示,根据系统版本进行展示,
销售版本客户称为”客户“,运营版本客户称为”用户“,原来代码里只是常量字符串,现在需要根据版本进行替换。需求很简单,创建一个全局变量,然后对源码进行一一替换即可。
一搜代码发现, 替换的工作量巨大,有近2000个地方包含了”客户“二字,手动替换肯定是不现实的。
拆解目标
拆解下我们的目标, 我们需要在包含有效(注释中不替换)”客户“的代码文件中引入全局变量,
1 2
| 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再转化为修改后的源代码
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
| t.ifstatement(test, consequent, alternate);
|
1 2 3 4 5
| t.isIfstatement(node, 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) { const ast = parser.parse(source, { sourceType: 'module', presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'], plugins: [ 'jsx', 'classProperties', 'typescript', 'moduleStringNames', 'exportNamespaceFrom', 'exportDefaultFrom', 'dynamicImport', 'asyncGenerators', ['decorators', { decoratorsBeforeExport: !1 }], 'asyncDoExpressions', 'doExpressions', 'functionBind', 'throwExpressions', ], });
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
| 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) { 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)) { 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); 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)) { 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、此类替换思维还可以用于例如国际化场景中等。