前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >手写第一个 babel 插件

手写第一个 babel 插件

作者头像
疯狂的技术宅
发布2020-12-31 14:25:38
7810
发布2020-12-31 14:25:38
举报
文章被收录于专栏:京程一灯京程一灯
代码语言:javascript
复制

在前文《babel是怎样工作的》中介绍了 Bable 中的的AST,这次咱们给 bable 写一个插件,文中会覆盖大部份的用法,如果你对某些细节不是很明白,可以去看一下官方的 Babel 手册,中文版在这里:

https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md

不过有的部分还没有翻译完。

访问节点

首先要找到要修改的节点,假设我们要帮一个特定的函数 myFunction 加上调试用的信息,在这里只加上文件名就行了,而这个 myFunction 长成这样:

代码语言:javascript
复制
function myFunction(data, optionalFilename)

使用者可以自己在 optionalFilename 加上想要的名字,或者用插件加上去,第一步就是打开 AST Explorer(https://astexplorer.net/)并写一个简单的测试程序来确定 AST 应该是什么样的:

代码语言:javascript
复制
myFunction(foo, __filename)

结果是这样:

AST

由于我们要能够判断使用者传入的几个参数,也要能确定使用者是在调用我们的函数,所以应该在 CallExpression 中进行处理:

代码语言:javascript
复制
// babel 的 plugin 可以用 module.exports 或 es6 的 export default 
// 函数的第一个参数是使用者正在使用的 `@babel/core`
module.exports = function ({ types: t }) {
  return {
    name: 'add-debug-information', // plugin 的名字,个加不加都行
    // pre(state) {}, // 要处理一个新的档案时会调用这个函数
    // post(state) {}, // 文件处理完成时要调用的函数
    visitor: {
      CallExpression(path) {
        console.log(path) // 这样就可以得到 CallExpression
      },
      // babel 可以在进入或是离开节点时调用 plugin 的函数,不过因为通常会需要在进入节点时处理,
      // 所以 babel 让使用者可以简写成上面那样,如果要在进入和离开时存取节点的话要写成像下面这样
      // CallExpression: {
      //   enter() { // 进入时
      //   },
      //   leave() { // 离开时
      //   },
      // }
    }
  }
}

判断是否为目标节点

下一步就是要判断是不是我们要做处理的节点了,这里先只简单的判断两个条件,函数名是 myFunction 并且只能用一个参数:

代码语言:javascript
复制
// 这里只写 CallExpression 的内容
if (
  t.isIdentifier(path.node.callee, { name: 'myFunction' }) && // 判断函数名是 `myFunction` ,这里的 t 就是 babel 传来的 types
  // 另外也可以直接判断 node 的 name,比如:t.isIdentifier(path.node.callee) && path.node.callee.name === 'myFunction'
  path.node.arguments.length < 2) { // 确定没有传入第二个参数
  // 处理目标节点
}

如果要判断的目标比较复杂,目前也没有比较好的方法,只能这样比较。另外因为 babel 中只能拿到到 AST 信息,如果要判断类型等几乎是没有什么办法的,所以实际在写插件时必须考虑所有合理的写法,如果真的没办法处理时一定要要告诉使用者必须按照某种格式写,否则不会被处理

修改节点

在已经找到目标目标的前提下,要把文件名加入到参数中。这里直接加入 node 中的 ``__filename` 变量,这个变量在 node 的模块中是那个原始码文件的文件名。

代码语言:javascript
复制
// 在上面的 if 中
path.pushContainer('arguments', t.identifier({ name: '__filename' }))
// 如果要加载开头,可以用 unshiftContainer

那么为什么要用 pushContainer 修改 AST 的内容呢?直接用 push 加到 arguments 中不行吗?这里最大的差别在于 plugin 新增了节点,如果有上游的添加、删除等改变,babel 也必须要便利新的节点,所以要用 babel 的 API 让它知道有节点被改变了。

完整的代码如下:

代码语言:javascript
复制
module.exports = function ({ types: t }) {
  
  return {
    name: 'add-debug-information',
    visitor: {
      CallExpression(path) {
        if (t.isIdentifier(path.node.callee, { name: 'myFunction' }) && path.node.arguments.length > 1) {
          path.pushContainer('arguments', t.identifier('__filename'))
        }
      }
    }
  }
}

接下来再来看看其他例子。

移除节点

假如要在正式环境把除错信息移除的话,就把 myFunction 第二个以后的参数都移除掉:

代码语言:javascript
复制
module.exports = function ({ types: t }) {
  return {
    visitor: {
      CallExpression(path) {
        if (t.isIdentifier(path.node.callee, {name: 'myFunction'})) {
          while (path.node.arguments.length > 1) { // 只要参数数量超过 1 个
            path.get(`arguments.1`).remove() //就把第二个参数移除,而下一个会补上来,所以下一次循环会再移除掉下一个
          }
        }
      }
    }
  };
}

pathget 可以用于获取指定位置的 Path 对象,可用于处理特定的子节点。

替换节点和 template

这次需求变成了在代码中加上对 NODE_ENV 的判断,如果是生产环境就不要除错信息,结果像这样:

代码语言:javascript
复制
// 原来
myFunction(data)

// 变为
process.env.NODE_ENV === 'production' ? myFunction(data) : myFunction(data, __filename)

❝通常上面的代码在正式环境中并不会真的多出一个判断,因为一般 bundler 会把 NODE_ENV 换成字串常量,然后再由 minifier 移除掉不需要的部分。 ❞

因为要产出的代码变多了,这次就用 template

代码语言:javascript
复制
module.exports = function ({ types: t, template }) {
  // 这里用到的 `%%data%%` 代表稍后我们可以放节点去取代那个位置,只需要用两个 `%` 包起来即可,
  // 这个是 babel 7.4 以后才支持的语法,如果想支持以前的版本,就要把它改成 `DATA` (一定要全大写)
  // template 的返回值是一个函数
  const tpl = template(`process.env.NODE_ENV === 'production'
  ? myFunction(%%data%%)
  : myFunction(%%data%%, %%source%%);`)
  
  // 用来标记已经遍历过的节点,用 Symbol 可以防止产生命名上的冲突
  const visited = Symbol()
  
  return {
    visitor: {
      CallExpression(path) {
        // 检查节点是否遍历过
        if (path.node[visited]) {
          return
        }

        if (t.isIdentifier(path.node.callee, {name: 'myFunction'})) {
          // 替换节点
          path.replaceWith(
            // tpl 是一个函数,只需要把 placeholder 的部份传进去,就会返回 AST
            tpl({
              // 这里要避免使用者没有传入第一个参数的情况,不然后面的参数会变成第一个参数
              // 也可以抛出 error 或者让 myFunction 在运行时进行判断
              data: path.node.arguments[0] || t.identifier('undefined'),
              // 如果使用者自己提供了除错信息,那么就用使用者提供的,不然就用 __filename
              source: path.node.arguments[1] || t.identifier('__filename')
            })
          )
          // 把节点下的 `myFunction` 都标记为遍历过
          path.node.consequent[visited] = true
          path.node.alternate[visited] = true
        }
      }
    }
  }
}

前面说过,要是新加入节点的话,babel 也会去遍历它,而我们加入的节点中就包含了要处理的目标节点,如果不进行特殊处理的话就会一直无限的遍历下去,所以要给添加的节点加上自己的标记,这样就可以避免重复处理。

抛出 error

在上一个例子中,为了要避免使用者少传参数而给了默认值,那如果要在少传参数时抛出错误又要怎么做呢。

代码语言:javascript
复制
module.exports = function ({ types: t, template }) {
  // 和上一个例子差不多
  const tpl = template(`process.env.NODE_ENV === 'production'
  ? myFunction(%%data%%)
  : myFunction(%%data%%, %%source%%);`)
  
  const visited = Symbol()
  
  // 创建一个函数来帮助抛出 error
  function throwMissingArgument(path) {
    // 这里用 path 上的 buildCodeFrameError ,这样显示的时候就能够标记有问题的代码在什么地方
    throw path.buildCodeFrameError('`myFunction` required at least 1 argument')
  }
  
  return {
    visitor: {
      CallExpression(path) {
        if (path.node[visited]) {
          return
        }

        if (t.isIdentifier(path.node.callee, {name: 'myFunction'})) {
          path.replaceWith(
            tpl({
              // 这里改用 throwMissingArgument
              data: path.node.arguments[0] || throwMissingArgument(path),
              source: path.node.arguments[1] || t.identifier('__filename')
            })
          )
          path.node.consequent[visited] = true
          path.node.alternate[visited] = true
        }
      }
    }
  }
}

如果没传参数的话应该会看到 babel 输出了这样的 error

代码语言:javascript
复制
code.js: `myFunction` expect at least 1 argument
> 1 |   myFunction()
    |   ^^^^^^^^^^^^
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-12-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端先锋 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 访问节点
  • 判断是否为目标节点
  • 修改节点
  • 移除节点
  • 替换节点和 template
  • 抛出 error
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档