前端工程化(8):编写一个babel插件来解决实际项目中的问题

3,194 阅读8分钟

在笔者的上一篇文章前端工程化(7):你所需要知道的最新的babel兼容性实现方案中剖析了在实际项目中如何使用babel提供的原生转译能力,得益于babel强大的转译能力我们无需再担心项目的兼容性问题。但是babel不只是一款帮助我们处理代码兼容性的工具,我们还可以借助它的插件化能力完成日常工作中一些重复、繁琐的工作。本文将笔者从在实际项目中碰到的问题而萌生用babel来解决的想法,到一个完整的babel插件的落地过程做了个总结,向大家展示在面对实际项目中的某些问题时用babel插件来解决有多香!!

1. 实际项目中出现的问题

项目中会经常用到element-ui中的$confirm来提示用户进行二次确认,比如在进行删除操作时应当都唤出是否确认删除的提示:

handleDelete (row) {
  this.$confirm('是否删除该条数据?', '提示', {
    type: 'warning'
  }).then(async () => {
    this.loading = true
    const res = await this.$delete('/api/xx', { id: row.id })
    if (!res) return
    await this.loadList()
    this.$message({
      type: 'success',
      message: '删除成功!'
    })
  })
}

上面是我们团队统一约定的删除逻辑编写方式。这么写没啥大问题,但是当我们取消二次确认弹框的时候,浏览器会提示错误:

image.png

这个错误想必大家也并不陌生,就是promise错误没有捕捉。是的,因为我们没有写catch,因为我们觉得没有什么必要的逻辑要在取消的时候触发(包括提示取消删除之类的)。

虽然这个错误对程序运行没有影响,但是对不熟悉的开发人员定位错误以及错误监控系统都会造成多余的困扰。我们也不好改组件源码,只好强制要求团队成员在每个$confirm后面手动加上catch逻辑:

handleDelete (row) {
  this.$confirm('是否删除该条数据?', '提示', {
    type: 'warning'
  }).then(async () => {
    this.loading = true
    const res = await this.$delete('/api/xx', { id: row.id })
    if (!res) return
    await this.loadList()
    this.$message({
      type: 'success',
      message: '删除成功!'
    });
  }).catch(err => err)
}

代码中充斥着大量的使用$confirm的逻辑,如果靠人力去手动解决这种重复性的问题,一方面增加了工作量,另一方面不能避免会有团队成员疏忽。

有位大佬曾说过:当你在做着一些重复性的工作时,那一定有别的办法来帮助你快速的完成它。这时脑子里就萌生了babel插件来自动添加catch的想法...

2. 编写插件前的准备工作

之所以萌生了用babel插件的想法,那是因为babel是从底层将我们代码解析成AST树,然后对AST树的节点进行递归遍历,在遍历的过程中,如果有插件则会执行插件中的逻辑对节点进行增删改,最后将修改过的AST树再生成代码,从而实现代码的修改。所以,在编写插件前,我们首先要分析对应代码的AST树结构,以及插件的运作方式。

2.1 AST 树结构分析

首先需要借助astexplorer来分析原代码的AST树结构,以及目标代码的AST树结构。对比两者结构的差异,从而才能找到转换的切入点

原代码的主结构为:

this.$confirm().then()

解析的AST树结构(简化):

{
  "type": "ExpressionStatement",
  "expression": {
    "type": "CallExpression",
    "callee": {
      "type": "MemberExpression",
      "object": {
        "type": "CallExpression",
        "callee": {
          "type": "MemberExpression",
          "object": {
            "type": "ThisExpression"
          },
          "property": {
            "type": "Identifier",
            "name": "$confirm"
          }
        },
        "arguments": []
      },
      "property": {
        "type": "Identifier"
        "name": "then"
      }
    },
    "arguments": []
  }
}

目标代码的主结构为:

this.$confirm().then().catch()

解析的AST树结构(简化):

{
  "type": "ExpressionStatement",
  "expression": {
    "type": "CallExpression",
    "callee": {
      "type": "MemberExpression",
      "object": {
        "type": "CallExpression",
        "callee": {
          "type": "MemberExpression",
          "object": {
            "type": "CallExpression",
            "callee": {
              "type": "MemberExpression",
              "object": {
                "type": "ThisExpression",
              },
              "property": {
                "type": "Identifier",
                "name": "$confirm"
              }
            },
            "arguments": []
          },
          "property": {
            "type": "Identifier",
            "name": "then"
          }
        },
        "arguments": []
      },
      "property": {
        "type": "Identifier"
        "name": "catch"
      }
    },
    "arguments": []
  }
}

关于babel是怎么将代码转换成AST树结构的在这里不再阐述,这里就大概分析下代码是如何跟AST树对应上的:

  • 首先,这行代码被称为表达式语句,所以这行代码的顶级节点的type就为ExpressionStatement(在javascript中,一行代码要么是表达式要么是声明,所以AST树的顶级节点类型要么是Statement要么是Declaration)。

  • 其次,这行代码是个调用表达式,所以次级节点的typeCallExpression。这里的调用顺序的解析要注意,是从右往左依次解析的,可以理解为「「「this.$confirm()」.then()」.catch()」

  • 接着,这个调用表达式的被调用者是成员表达式,所以接下来的节点的typeMemberExpression,而成员表达式的访问的对象又是一个调用表达式,调用表达式的被调用者又是成员表达式,依次类推,一直解析到this.$confirm为止。

我们可以看到,this.$confirm.then().catch()的树结构在this.$confirm.then()树结构的基础上多了一层调用表达式节点,被调用者是个成员表达式,而成员表达式的对象就是this.$confirm.then()最外层的调用表达式节点。

知道这个特征后,我们接下来就可以利用插件来完成转换。

2.1 babel 插件结构分析

一个插件的基本结构如下所示:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      ...
    }
  }
}

可以看出,babel插件其实就是个函数,入参是babel对象,其中包含了babel所有的工具对象。最常用的是types工具对象,我们在编写插件的时候基本都需要依赖它提供的创建AST节点、验证AST节点的方法。

创建一个节点可以通过types调用该节点名称对应的方法:

t.identifier('a')

验证一个节点可以通过types调用is + 该节点名称对应的方法:

t.isIdentifier(node)

types对象实际上是babel-types包的映射。AST树节点类型众多,当你需要调用某个节点的校验和创建方法时,可以查阅babel-types文档。

插件函数最后会返回一个对象,对象里面定义一个visitor(访问者)属性。在visitor中可以定义你想要访问的节点类型,节点类型以函数的形式定义,这样当AST遍历到你想要访问的节点类型时,则会执行你定义的节点类型方法。比如你想要访问CallExpression类型节点,并对这节点做一些操作:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression (path) {
        // do sth
      }
    }
  }
}

节点类型方法接收一个path(路径)参数,path表示两个节点之间连接的对象,path中存储着当前AST节点信息以及一些节点操作方法,列举几个常用的:

  • path中的属性:

    • node - 当前遍历到的节点信息
    • parent - 当前遍历到的节点信息的父节点信息
    • parentPath - 当前遍历到的节点的父节点路径
    • scope - 作用域
  • path中的方法:

    • findParent - 找寻特定的父节点
    • getSibling - 获取同级路径
    • getFunctionParent - 获取包含该节点最近的父函数节点
    • getStatementParent - 获取包含该节点最近的表达式节点
    • relaceWith - 替换一个节点
    • relaceWithMultiple - 用多节点替换单节点
    • insertBefore - 在之前插入兄弟节点
    • insertAfter - 在之后插入兄弟节点
    • remove - 删除节点
    • pushContainer - 将节点插入到容器中
    • stop - 停止遍历
    • skip - 跳过此次遍历

具体使用可以查阅babel-handbook 文档,建议仔细阅读。

当有一个节点类型方法的访问者时,实际上是在访问该节点的路径而非节点本身。我们对节点的操作也都是在操作路径,而不是节点本身

所以,插件都是通过修改path对象来修改AST结构。我们只要合理运用path提供的属性和方法,再辅以babel-types提供的校验、创建节点能力,就可以简单的完成AST树节点的增删改。

3. 开始编写插件

3.1 环境搭建

我们需要搭建一个环境来方便开发、调试以及发布babel插件。首先安装几个babel的核心包:

"devDependencies": {
  "@babel/cli": "^7.14.5",
  "@babel/core": "^7.14.6",
  "@babel/preset-env": "^7.14.7"
}

引入@babel/preset-env预设的目的一是为了将插件源码在打包时进行兼容性转换,二是为了在测试的时候模拟使用了@babel/preset-env预设的环境。

新建文件夹srctestsrc中存放插件源码,test中存放测试用例,然后配置打包和调试命令:

"scripts": {
  "build": "rm -rf lib && babel src/index.js -d lib",
  "test": "babel test/index.js -d test/compiled --watch"
},

最后新建babel.config.js文件并配置plugins,该配置项是一个数组,表示babel需要加载的插件列表,我们将其指向自定义插件的路径就可以:

var config = {
  presets: [
    ['@babel/preset-env']
  ]
}
// 执行 npm run test 时才启用插件
if (process.argv[2].indexOf('test') >= 0) {
  config.plugins = [
    ["./src/index.js"]
  ]
}

module.exports = config

调试时只需要事先在test/index.js文件中编写好几个测试用例,然后在src/index.js中编写插件逻辑,重新执行npm run test,最后在test/compiled/index.js文件中查看编译的结果即可。

3.2 逻辑编写

插件的逻辑编写其实不难,关键是要找准我们应该访问哪种节点类型。对于this.$confirm().then()代码不难看出我们要访问的节点类型是CallExpression,然后通过CallExpression节点找到this.$confirm所在的节点,找到则继续往下执行,没有找到则提前退出:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression (path) {
        const { node } = path
        if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
          return
        }
      }
    }
  }
}

如果找到this.$confirm所在的节点,则沿着当前节点路径去搜寻父节点中是否有包含catch的节点,没找到则继续往下执行,找到的话则不做任何操作提前退出:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression (path) {
        const { node } = path
        if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
          return
        }
        
        const catchPath = path.findParent(({ node }) => {
          return t.isMemberExpression(node) && isObjectProperty(node.property, 'catch')
        })
        if (catchPath) {
          return
        }
      }
    }
  }
}

如果没有在父节点路劲中找到catch所在的节点,先获取最外层的then所在的节点做好构建新节点的准备:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression (path) {
        const { node } = path
        if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
          return
        }
        
        const catchPath = path.findParent(({ node }) => {
          return t.isMemberExpression(node) && isObjectProperty(node.property, 'catch')
        })
        if (catchPath) {
          return
        }
        
        const mostOuterThenPath = path.findParent(pPath => {
          const node = pPath.node
          return t.isCallExpression(node) && isObjectProperty(node.callee.property, 'then') && !t.isMemberExpression(pPath.parentPath.node)
        })
      }
    }
  }
}

这里获取最外层then所在节点的办法是判断当前节点的父节点是否是MemberExpression,如果不是则是最外层then所在的节点。

获取到最外层then所在的节点以后,就用它构建一个callExpression新节点,并替换掉它:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression (path) {
        const { node } = path
        if (!(t.isMemberExpression(node.callee) && t.ThisExpression(node.callee.object) && t.isIdentifier(node.call.property, { name: '$confirm' }))) {
          return
        }
        
        const catchPath = path.findParent(({ node }) => {
          return t.isMemberExpression(node) && isObjectProperty(node.property, 'catch')
        })
        if (catchPath) {
          return
        }
        
        const mostOuterThenPath = path.findParent(pPath => {
          const node = pPath.node
          return t.isCallExpression(node) && isObjectProperty(node.callee.property, 'then') && !t.isMemberExpression(pPath.parentPath.node)
        })
        
        const arrowFunctionNode = t.arrowFunctionExpression(
          [t.identifier('err')],
          t.identifier('err')
        )

        const newNode = t.callExpression(
          t.memberExpression(
            mostOuterThenPath.node,
            t.identifier('catch')
          ),
          [arrowFunctionNode]
        )

        mostOuterThenPath.replaceWith(newNode)
      }
    }
  }
}

完成上述babel插件逻辑(忽略了一些边界情况),就可以实现this.$confim().then()this.$confim().then().catch(err => err)的转换了。

4. 升级成通用方案

上面的babel实现方案只针对this.$confirm来做catch的添加,插件要是只有这个功能未免也太鸡肋了。所以决定把这个方案升级成通用方案,这个通用方案支持的场景有:

  • 不强制规定只给成员访问形式的Promise添加catch,也就是说可以给this.$confirm$confirmthis['$confirm']MessageBox.confirm形式的Promise添加catch

  • 用户可以选择为指定的Promise添加catch,如果不选择则给所有Promise都添加catch

借助babel提供的插件选项,在babel.config.js中修改配置:

config.plugins = [
  ["./src/index.js", {
    promiseNames: ['$confirm', '$prompt', '$msgbox']
  }]
]

promiseNames中定义的选项会通过状态对象传递给插件访问者:

module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression (path, { opts }) {
        console.log(opts.promiseNames) // ['$confirm', '$prompt', '$msgbox']
      }
    }
  }
}
  • 支持自定义catch的回调逻辑,如果不定义,则默认回调时err => err

也是借助babel提供的插件选项,在babel.config.js中修改配置:

config.plugins = [
  ["./src/index.js", {
    catchCallback: 'console.log(err)'
  }]
]

同时还借助babel提供的template工具,将字符串转换成AST节点:

module.exports = ({ types: t, template }) => {
  return {
    visitor: {
      CallExpression (path, { opts }) {
        console.log(opts.catchCallback) // console.log(err)
        ...
        const arrowFunctionBody = !catchCallback
          ? t.identifier('err')
          : t.BlockStatement([
            template.ast(catchCallback)
          ])
        const arrowFunctionNode = t.arrowFunctionExpression(
          [t.identifier('err')],
          arrowFunctionBody
        )
        ...
      }
    }
  }
}

完整的代码请戳这github.com/pandly/babe…

5. 在实际项目中调试

经过上述几个步骤的操作,一个完整的babel插件就基本完成了。接下来,就是在实际项目中进行测试了,本地调试可以用npm link指令来操作:

$ # 先去到模块目录,把它 link 到全局
$ cd path/to/babel-plugin-promise-add-catch
$ npm link
$
$ # 再去项目目录通过包名来 link
$ cd path/to/my-project
$ npm link babel-plugin-promise-add-catch

然后在实际项目的babel配置文件中加上:

plugins: [
 [
  'promise-add-catch',
    {
      promiseNames: ['$confirm', '$prompt', '$msgbox'] // 如果有需要
      catchCallback: 'console.log(err)' // 如果有需要
    }
  ]
]

启动项目,删除代码中的catch,如果控制台没有报错,则说明大功告成!

6. 总结

总体来说,babel插件的编写入门还是比较简单的,但是要想写好却不是那么简单。入门简单是因为插件化结构清晰,api封装的强大,文档比较健全;而要想写好,首先要熟悉代码对应的AST树结构,其次根据树结构来完成逻辑的编写,最后要考虑边界情况来保证代码的健壮性。

在编写插件的时候碰到两个问题,一直没有找到答案:

  1. 都说是plugins优先于presets执行,可是在测试async函数时很明显感受到是presets优先于plugins;

  2. 本地demo使用@babel/preset-env会把catchfinally方法编译成计算的形式['catch'](),但是在真实项目中却不会。

以上两个问题有知道的大佬可以在评论区告诉我,非常感谢~~

个人觉得这个插件还是挺实用的,并且已经推广到团队中去了,哈哈哈哈~~

最后,这个插件已发布到npm,插件地址babel-plugin-promise-add-catch,欢迎各位使用,也欢迎各位提出意见~~