用一个Babel插件简化重复工作(二),不为解放双手只能为有机会实践

91 阅读8分钟

距离上一次的文章 用一个Babel插件简化重复工作(一),解放双手只能解放🤏一点点 过去过去了整整的一天了,我又来了,还是继续把这次的写完有始有终

背景提要

继续上一篇的开始场景:

对几十个业务系统进行框架的替换,大量的代码需要进行手动修改。

每个项目这么大范围的改动下,保障几十个项目的顺利升级,就需要思考以下的问题了:

  1. 如何保证时效性,毕竟迭代周期也有限,不可能停下来专门升级而不进行需求的迭代;

  2. 如何才能最小改动,毕竟最小改动就意味着工作量的陡降;测试范围的缩小;时间上的节省;

  3. 几十个项目的升级,平均每个人负责3个左右的项目升级,能不能有什么方法能够批量处理;

简单小小的总结一下这几个问题的关键点就是,时间,工作量,准确性,大量重复操作;

分析问题

1. 针对接口API函数的调用修改

我们先看一下现在的API调用形式基本可以罗列出来以下几种场景;

导入整个对象

import systemApi from '../api/main'

导入具体使用到的函数

import { getUser , getUserDetail } from '../api/main/index'

导入某些具体的函数然后使用别名

import { getUser as currentUser , getUserDetail } from '../api/main/index'

既导入整个对象 也导入具体的某些函数

import systemApi,{ getUser , getUserDetail } from '@/api/main/index'

基本上就是这么几种语法,或者说常用的就这么几类

那么与之对应的相关函数的调用也就是如下的几种了:

使用 await 对象.函数的形式 awit systemApi.getUser({...})

使用 对象.函数 .then 的形式; systemApi.getUser({...}).then(()=>{...})

使用 函数 .then 的形式; getUser({...}).then()

使用 wait 函数 的形式; await getUser({...})

这几种类型 我们统统都需要改成从this上调取服务的函数的形式,说起来有些拗口,简单直白点就是如下的形式:

this.$apis.systemApi.getUset(...)

这样就对应了apis 目录下 systemApi.js 中 的 getUser 函数; 简单点说就是通过框架本身的功能自动扫描 apis 目录下的所有js文件并注册好已文件名为服务名的 api 服务;

这看上去代码更加的有可读性;

明确了现阶段的语法,以及需要改造后的目标语法;

我们就可以开始构思如何去着手修改了;

那最简单的肯定是全局替换,精通正则表达式的同学搭配全局批量替换也能有效的减少手动修改的频次;

那我们今天要说的肯定不是基于正则的全局替换方式;我们要讲的依然是使用Babel 插件,对源代码进行精确修改;

那整体的流程依然是 经典的三步走:

读取源码转AST =〉 修改AST =〉通过AST生成目标代码;

读取源码 转 AST 这个比较简单

首先我们的目标是要修改 .js 和 .vue

针对js文件我们可以直接转AST 如下:

async function changeContentForJs(targetfile){
    let originCode = fs.readFileSync(path.resolve(targetfile))
    originCode = originCode.toString()
    const targetSource = core.transform(originCode,{
        plugins:[changeApiUsedContentPlugin()]
    })
    const code = targetSource.code
    await fs.writeFileSync(path.resolve(targetfile.replace('.js','_new.js')),code.toString(), 'utf8');
}

那么针对于.vue 的文件 我们需要解析出script部分的代码 然后进行 如上的操作;

那么解析.vue 文件我需要借助 vue 官方提供的一个包 @vue/compiler-sfc 参考代码如下:

import { parse } from '@vue/compiler-sfc'
const vueContent = await fs.readFileSync(targetfile, 'utf-8');
const result = parse(vueContent)
const script = result.descriptor.script
const originCode = script.content

这样就拿到了 vue 文件中的js 部分了

剩下的就简单了 我们只需要写一个 Babel 插件,分析源码中 那些地方import 了 api目录下的文件

然后针对 import 语句 找到 源码中的应用的函数名称就可以找到对应的语句;

如果vue 中 如果 包含了 jsx 我们还需要使用一个babel 插件 "@babel/plugin-transform-react-jsx" 这样就可以解析出对应的vue文件了;

我们的代码替换插件就如下了:


export const changeApiUsedContentPlugin=()=>{
    return {
        visitor:{
            JSXElement(path,state){
                const {node} = path
                debugger
                console.log("+++++++++++++++++++++++++++++++++++++",node.type)
            },
            'MemberExpression|CallExpression'(path,state){
                const {node} = path
                const nodeType = node.type
                const rootNode = path.findParent((p) => {
                    return p.type==='Program'
                });
                const allImportStatement = rootNode.node.body.filter((item)=>{
                    return item.type ==='ImportDeclaration'
                })

                console.log("allImportStatement.length:",allImportStatement.length)

                allImportStatement.forEach((importNode)=>{
                    // console.log('importPath>>>',importNode)
                    let formStr = importNode.source.value
                    console.log("formStr>>>>>>>>>>>>>>>>>",formStr)
                    formStr = 'api/'+formStr.split('api/')[1]
                    const t = importNode.source.value.split('/')
                    const apiFileName = t[t.length-1].replace('.js','')
                    const t1 = ['index','Index'].includes(apiFileName) ? t[t.length-2]:apiFileName
                    const serveName = t1 // 通过文件名 确定 apiServeName

                    console.log('serveName',serveName)

                    // 场景一: import systemApi from '../../api/systemApi.js'
                    const specifiers = importNode.specifiers //[0].local.name
                    // console.log("specifiers.length",specifiers.length)
                    specifiers.forEach((specifier)=>{
                        let apiName = specifier.local.name
                        // console.log("api name>>>>",apiName)
                        // console.log(specifier.type)
                        // 类似:import systemApi from '../../api/systemApi.js'
                        // console.log('specifier.type====>',specifier.type)
                        if(specifier.type==='ImportDefaultSpecifier'){
                            console.log("nodeType:",nodeType)
                            // systemApi.a => this.$apis.systemApi.a
                            if(nodeType==='MemberExpression'){
                                const name = node.object.name
                                // console.log("MemberExpression >>>>>",apiName,name)
                                if(name===apiName){
                                    node.object.name= 'this.$apis.'+name
                                }
                            }

                        }

                        // 类似: import {a,b} from '../../api/systemApi.js'
                        if(specifier.type==='ImportSpecifier'){
                            const serverName = formStr
                            // console.log("apiName++++++++++++++++",apiName)
                            // a => this.$apis.systemApi.a
                            if(nodeType==='CallExpression'){
                                const name = node.callee.name
                                // console.log(">>>>>>>>>>>>.",name)
                                // console.log("CallExpression >>>>>",serverName,apiName,name)
                                if(name && name===apiName){
                                    const newName = `this.$apis.${serveName}.`+name
                                    node.callee.name= newName
                                }
                            }

                        }

                    })




                })

            }
        },
    }
}

这样我们就处理好了 api 的 替换了,怎么样是不是 很简单呀;

相信看到了这里基本就不会觉得繁杂的;

2.移除相关API文件的import语句

由于第一步中的API应用的内容已经完成了替换;那么我们以前import api 文件的语句其实就没有任何存在的意义了; 这个时候eslint 就会检查到 这些import 语句 没有被使用;

那么处理问题的方式有很多种:比如 直接吧eslint 对应的规则给关掉即可;

但这明显不是我们的做事的风格:我们依旧喜欢保持代码的整洁与优雅:

那我们必须选择另一种方式来处理了

那就是全部移除这类没有被引用的import 语句;

3. 编写插件替换源码内容

那为了效率和准确性 我们同样还是一样的逻辑写一个Babel 插件:

大体思路就是查找所有的import 语句, 找出所有的路径

过滤出目标路径 这里我们过滤 ‘/api/’

把该语句删除掉

重新生成代码,大功告成;

参考代码如下:

export const changeApiUsedContentPlugin=()=>{
    return {
        visitor:{
            JSXElement(path,state){
                const {node} = path
                debugger
                console.log("+++++++++++++++++++++++++++++++++++++",node.type)
            },
            'MemberExpression|CallExpression'(path,state){
                const {node} = path
                const nodeType = node.type
                const rootNode = path.findParent((p) => {
                    return p.type==='Program'
                });
                const allImportStatement = rootNode.node.body.filter((item)=>{
                    return item.type ==='ImportDeclaration'
                })

                console.log("allImportStatement.length:",allImportStatement.length)

                allImportStatement.forEach((importNode)=>{
                    // console.log('importPath>>>',importNode)
                    let formStr = importNode.source.value
                    console.log("formStr>>>>>>>>>>>>>>>>>",formStr)
                    formStr = 'api/'+formStr.split('api/')[1]
                    const t = importNode.source.value.split('/')
                    const apiFileName = t[t.length-1].replace('.js','')
                    const t1 = ['index','Index'].includes(apiFileName) ? t[t.length-2]:apiFileName
                    const serveName = t1 // 通过文件名 确定 apiServeName

                    console.log('serveName',serveName)

                    // 场景一: import systemApi from '../../api/systemApi.js'
                    const specifiers = importNode.specifiers //[0].local.name
                    // console.log("specifiers.length",specifiers.length)
                    specifiers.forEach((specifier)=>{
                        let apiName = specifier.local.name
                        // console.log("api name>>>>",apiName)
                        // console.log(specifier.type)
                        // 类似:import systemApi from '../../api/systemApi.js'
                        // console.log('specifier.type====>',specifier.type)
                        if(specifier.type==='ImportDefaultSpecifier'){
                            console.log("nodeType:",nodeType)
                            // systemApi.a => this.$apis.systemApi.a
                            if(nodeType==='MemberExpression'){
                                const name = node.object.name
                                // console.log("MemberExpression >>>>>",apiName,name)
                                if(name===apiName){
                                    node.object.name= 'this.$apis.'+name
                                }
                            }

                        }

                        // 类似: import {a,b} from '../../api/systemApi.js'
                        if(specifier.type==='ImportSpecifier'){
                            const serverName = formStr
                            // console.log("apiName++++++++++++++++",apiName)
                            // a => this.$apis.systemApi.a
                            if(nodeType==='CallExpression'){
                                const name = node.callee.name
                                // console.log(">>>>>>>>>>>>.",name)
                                // console.log("CallExpression >>>>>",serverName,apiName,name)
                                if(name && name===apiName){
                                    const newName = `this.$apis.${serveName}.`+name
                                    node.callee.name= newName
                                }
                            }

                        }

                    })
                    
                })

            }
        },
    }
}

4.如何反向生成vue文件呢

vue文件的AST已经按照我们的期望对节点进行了调整,那么我们又如何反向生成vue 文件呢;

这里我们尝试了好几种方式没有找到比较优雅的方式,在vue官方也没有找到相应的工具包,可能是是我的方式没有对导致的;

尝试的思路有:通过vue的AST生成,因为vue的AST中有包含template ,script,style 这几个节点的详细信息,包含了content,source等等关键信息;

如下的vue内容

<template>
  <p>{{ greeting }} World!</p>
</template>

<script>
export default {
  data () {
    return {
      greeting: "Hello"
    };
  }
};
</script>

<style scoped>
p {
  font-size: 2em;
  text-align: center;
}
</style>

对应生成的AST为:

{
  "type": 0,
  "children": [
    {
      "type": 1,
      "ns": 0,
      "tag": "template",
      "tagType": 0,
      "props": [],
      "isSelfClosing": false,
      "children": [
        {
          "type": 1,
          "ns": 0,
          "tag": "p",
          "tagType": 0,
          "props": [],
          "isSelfClosing": false,
          "children": [
            {
              "type": 5,
              "content": {
                "type": 4,
                "isStatic": false,
                "isConstant": false,
                "content": "greeting",
                "loc": {
                  "start": {
                    "column": 9,
                    "line": 2,
                    "offset": 19
                  },
                  "end": {
                    "column": 17,
                    "line": 2,
                    "offset": 27
                  },
                  "source": "greeting"
                }
              },
              "loc": {
                "start": {
                  "column": 6,
                  "line": 2,
                  "offset": 16
                },
                "end": {
                  "column": 20,
                  "line": 2,
                  "offset": 30
                },
                "source": "{{ greeting }}"
              }
            },
            {
              "type": 2,
              "content": " World!",
              "loc": {
                "start": {
                  "column": 20,
                  "line": 2,
                  "offset": 30
                },
                "end": {
                  "column": 27,
                  "line": 2,
                  "offset": 37
                },
                "source": " World!"
              }
            }
          ],
          "loc": {
            "start": {
              "column": 3,
              "line": 2,
              "offset": 13
            },
            "end": {
              "column": 31,
              "line": 2,
              "offset": 41
            },
            "source": "<p>{{ greeting }} World!</p>"
          }
        }
      ],
      "loc": {
        "start": {
          "column": 1,
          "line": 1,
          "offset": 0
        },
        "end": {
          "column": 12,
          "line": 3,
          "offset": 53
        },
        "source": "<template>\n  <p>{{ greeting }} World!</p>\n</template>"
      }
    },
    {
      "type": 1,
      "ns": 0,
      "tag": "script",
      "tagType": 0,
      "props": [],
      "isSelfClosing": false,
      "children": [
        {
          "type": 2,
          "content": "\nexport default {\n  data () {\n    return {\n      greeting: \"Hello\"\n    };\n  }\n};\n",
          "loc": {
            "start": {
              "column": 9,
              "line": 5,
              "offset": 63
            },
            "end": {
              "column": 1,
              "line": 13,
              "offset": 144
            },
            "source": "\nexport default {\n  data () {\n    return {\n      greeting: \"Hello\"\n    };\n  }\n};\n"
          }
        }
      ],
      "loc": {
        "start": {
          "column": 1,
          "line": 5,
          "offset": 55
        },
        "end": {
          "column": 10,
          "line": 13,
          "offset": 153
        },
        "source": "<script>\nexport default {\n  data () {\n    return {\n      greeting: \"Hello\"\n    };\n  }\n};\n</script>"
      }
    },
    {
      "type": 1,
      "ns": 0,
      "tag": "style",
      "tagType": 0,
      "props": [
        {
          "type": 6,
          "name": "scoped",
          "loc": {
            "start": {
              "column": 8,
              "line": 15,
              "offset": 162
            },
            "end": {
              "column": 14,
              "line": 15,
              "offset": 168
            },
            "source": "scoped"
          }
        }
      ],
      "isSelfClosing": false,
      "children": [
        {
          "type": 2,
          "content": "\np {\n  font-size: 2em;\n  text-align: center;\n}\n",
          "loc": {
            "start": {
              "column": 15,
              "line": 15,
              "offset": 169
            },
            "end": {
              "column": 1,
              "line": 20,
              "offset": 216
            },
            "source": "\np {\n  font-size: 2em;\n  text-align: center;\n}\n"
          }
        }
      ],
      "loc": {
        "start": {
          "column": 1,
          "line": 15,
          "offset": 155
        },
        "end": {
          "column": 9,
          "line": 20,
          "offset": 224
        },
        "source": "<style scoped>\np {\n  font-size: 2em;\n  text-align: center;\n}\n</style>"
      }
    }
  ],
  "helpers": [],
  "components": [],
  "directives": [],
  "hoists": [],
  "imports": [],
  "cached": 0,
  "temps": 0,
  "loc": {
    "start": {
      "column": 1,
      "line": 1,
      "offset": 0
    },
    "end": {
      "column": 1,
      "line": 21,
      "offset": 225
    },
    "source": "<template>\n  <p>{{ greeting }} World!</p>\n</template>\n\n<script>\nexport default {\n  data () {\n    return {\n      greeting: \"Hello\"\n    };\n  }\n};\n</script>\n\n<style scoped>\np {\n  font-size: 2em;\n  text-align: center;\n}\n</style>\n"
  }
}

这种通过vue 的 AST 反向生成 vue 文件的思想肯定没有问题的;

这里我们并没有使用该方式进行生成,而是选择了一种更加简洁的方式处理:

基本思路就是script标签内的内容进行全部替换 这样的方式明显存在缺陷,但是它快啊,好用啊;

缺陷就是:当存在多个script 标签的时候,通过正则表达式替换 可能会错乱,当然在实际的使用过程中也确实体现了出来,还好我们项目这种场景的vue文件并不多就一两个而已,这里我们选择手动适配一下即可以

参考代码:

export async function replaceScriptContent(filePath,jsContent) {
    let res = await fs.readFileSync(path.resolve(filePath))
    let fileContent = res.toString()

    const scriptRegex = /<script[^>]*>([\s\S]*?)</script>/gi;
    let match;
    let oldContent = ''
    let newContent = ''
    while ((match = scriptRegex.exec(fileContent))) {
        oldContent = match[1]
        newContent =  fileContent.replaceAll(oldContent,'\r'+jsContent+'\r')
    }
    await fs.writeFileSync(path.resolve(filePath), newContent, 'utf8');
}

5.如何一次性尽可能的多处理完需要修改的文件

这个问题就非常简单了,依旧是找出目标目录的所有目标文件: 这里我们主要是 src下的 mixins,views,components 这几个目录的 js 和 vue 文件;

我们依旧使用工具函数查询出所有的文件:

export const targetVueFiles = getTargetFiles(['**/vue/src/**/*.vue'])
export const targetJsFiles = getTargetFiles([
    '**/vue/src/mixins/**/*.js',
    '**/vue/src/components/**/*.js',
    '**/vue/src/views/**/*.js'
],{ignore: ['node_modules/**','/api/**']})

这样就获取了所有需要处理的目标文件列表

通过辅助函数即可完成内容的更替

替换js类型的文件:

async function changeContentForJs(targetfile){
    let originCode = fs.readFileSync(path.resolve(targetfile))
    originCode = originCode.toString()
    const targetSource = core.transform(originCode,{
        plugins:[changeApiUsedContentPlugin()]
    })
    const code = targetSource.code
    await fs.writeFileSync(path.resolve(targetfile.replace('.js','_new.js')),code.toString(), 'utf8');
}


for (let i = 0; i<targetJsFiles.length; i++){
    const jsFile = targetJsFiles[i]
    await changeContentForJs(jsFile)
}

替换vue类型的文件:

async function changeContentForVue(targetfile){
    const vueContent = await fs.readFileSync(targetfile, 'utf-8');
    const result = parse(vueContent)
    const script = result.descriptor.script
    const originCode = script.content
    const targetSource = core.transform(originCode,{
        plugins:["@babel/plugin-transform-react-jsx",changeApiUsedContentPlugin()]
    })
    const code = targetSource.code
    await fs.writeFileSync(path.resolve(targetfile),code.toString(), 'utf8');
}

for (let i = 0; i<targetVueFiles.length; i++){
    const vueFile = targetVueFiles[i]
    await changeContentForVue(vueFile)
}

经过这几个步骤的处理,这一部分耗神费力的体力工作基本90%已经处理了,剩下的就是针对没有适配的特殊场景进行手动的修改了;

原本半个月的修改周期,我们仅用了两天不到三天的时间处理完了,其中一天写工具,半天运行调试修改,又用了半天处理手动修改的地方,以及大半天的全局点点点测试一下有没有报错的地方。

总结一下下

最后一句感叹:工具使人进步,可以有效节省时间,提升效率

同时也发出感慨:工具不能100%的替代手动重复工作,如果能处理80%以 上的工作量 个人觉得就是一个好用的工具;

当然我们可以追求无限趋近完美的工具这就需要权衡好各种利弊了。

现在回想起来前端的各种工具,webpack , vite , eslit 等都是对源代码基于AST做了各种各样的操作; 是不是一下就理解了他们的基本工作原理了呢;

这也是第一次开发使用 Babel 插件,同时也了解到了不少的有用信息,同时也得到实践的机会;

结局也甚是满意...

最后的最后

下面请上我们今天的主角:有请小趴菜

小趴菜.jpeg