likes
comments
collection
share

我是怎么开发一个Babel插件来实现项目需求的?

作者站长头像
站长
· 阅读数 23

前言

公司有一个通过可视化的配置来实现基础布局,常见的页面开发的低代码产品,比如表格、表单、树等等这类页面。也是基于组件化的一种开发理念,选中对应的组件时,可以进行对应的属性,事件的配置,以及个性化逻辑代码的编写。编辑代码的时候,可以调用封装的API和上下文对象实现对组件的状态更改,运行的时候就是读取数据库的页面配置动态渲染。

最近有一项新的研发任务,就是要把根据页面的配置数据生成页面对应的Vue文件。那这样的话,编写的代码运行肯定就会有很多问题,为了保证转出来的代码风格与手动开发的尽量保持一致。所以需要对JavaScript代码进行一些转换,同时还要做很多工作适配原来的API

先给大家看下设计页面: 我是怎么开发一个Babel插件来实现项目需求的?

代码编辑页面: 我是怎么开发一个Babel插件来实现项目需求的?

预览: 我是怎么开发一个Babel插件来实现项目需求的? 接下来呢,看下笔者要将用户写的JavaScript代码转换成什么样。

代码转换具体需求

在经过讨论和验证之后,终于把需要转换的内容以及要转换为什么内容定下来了,大致如下:

// 1. xp.xxx -> this.$xxx
// 2. ctx.get('xxx') -> this.$refs.xxx,这个要考虑以下情况:
//     -  ctx.get('xxx')
//     -  ctx.get(code)
//     -  ctx.get(codes[i])
// 3. ctx.data -> this
// 4. handleAddress(ctx) 如果调用的是最外层定义的function -> this.handleAddress()
// 5. handleAddress(ctx) 如果调用的是局部定义的function -> handleAddress()
// 6. const crud = ctx.get('crud'); crud.$doDelete(ctx); 
//    -> var crud = this.$refs.crud;this.$doDeleteCrud(arguments);
// 7. 去掉函数声明,和函数表达式中的第一个参数(如果是ctx,除了xp.templateFactory.xxx(ctx))
// 8. 保留的ctx参数,要替换为this

🤣大致就是这些需求吧。

转换实现

代码转换笔者借助的Babel的能力,通过开发一个Babel插件去实现代码的转换。@babel/core中有这么一个API: 我是怎么开发一个Babel插件来实现项目需求的?

Babel 的三个主要处理步骤分别是: 解析(parse)转换(transform)生成(generate)

Babel的插件开发有一个访问者模式(visitor) 的概念。当调用transform,在将js代码解析为抽象语法树之后,会对抽象语法树递归遍历到每个节点,根据节点的类型会调用插件中以节点的类型为名的函数,并把当前节点作为参数传入,然后就可以根据实际需要对节点作出更改。详情可参考Babel插件手册

那节点的类型有哪些❓

MemberExpression // 类似this.$refs.xxx
FunctionDeclaration // 函数声明 function a() {}
FunctionExpression // 函数表达式,比如回调函数,const s = function() {}
ArrowFunctionExpression // 箭头函数 () => {}
Identifier // func(argu),argu就是Identifier
CallExpression // 方法调用func(argu)
VariableDeclaration // 变量声明
// more...

了解完上面这些,我们就可以开始设计实现了,不知道各位掘友有没有这么一个习惯,就是在敲代码之前,要先弄清楚需求,然后在脑子里设计好了,这里的的设计就有很多学问了(实现的优不优雅,扩展性怎么样,可维护性怎么样......),做完这些咱们再去写代码也不迟。所谓磨刀不误砍柴工嘛。

设计实现

那我们先理理:

  1. 因为页面有多个,项目中页面是归属于应用,可以以应用为单位实现一个批量转换的脚本,调用一个命令全部转换
  2. 转换完需要更新数据库
  3. 抽取应用ID,请求地址,token的配置
  4. 实现代码转换
  5. 针对转换不成功的,要跳过并记录下失败原因

新建一个文件夹script,用来放即将实现的脚本: 我是怎么开发一个Babel插件来实现项目需求的?

package.json中添加两个脚本

 "transform": "node ./script/batchTransform.js",
 "transform-test": "node ./script/test.js"

其中第一个用于批量转换,第二个用于我们测试,写的代码总得测试的。

实现配置文件config.js

module.exports = {
  //开发
  URL_PREFIX: 'http://127.0.0.1:16005',
  //测试
  PROJECT_ID: 'P202206291406278582', // 要转换那个应用下的应用
  ACCESS_TOKEN: '' // 当连接网关时,需要配置token
}

实现batchTransform.js

这个很简单了,就是判断转换所须的配置,调用startTransform

#!/usr/bin/env node
// 命令
const config = require('./config.js')

const { startTransform } = require('./handle')

if (config.PROJECT_ID && config.URL_PREFIX) {
  startTransform()
}

实现test.js

#!/usr/bin/env node
// 命令
const config = require('./config.js')

const { functionTransForm } = require('./handle')

console.log(functionTransForm(``)) // 调用functionTransForm,用于测试我们自己写的Babel插件好不好用

实现handle.js

const fetch = require('node-fetch')

const chalk = require('chalk')

const config = require('./config.js')

const visitor = require('./visitor.js')

const Babel = require('@babel/core')

const { log } = console
// 进度
const ora = require('ora')
let spinner

function functionTransForm(code) {
  const transcode = Babel.transform(code, {
    sourceType: 'script',
    plugins: [
      {
        visitor
      }
    ]
  }).code
  // 去掉注释的function // function /* function
  const patternNoUse = /(\/\/+|\*)\s*function{1}[\s]*/g
  const pattern = /function{1}[\s]*/g
  let index = 0
  return transcode
    .replace(patternNoUse, match => {
      return match.startsWith('*') ? '* ' : '// '
    })
    .replace(pattern, () => {
      const res = index === 0 ? '' : ','
      index++
      return res
    })
}

// 请求头
const Authorization = 'Bearer ' + config.ACCESS_TOKEN

function getPages() {
  return fetch(config.URL_PREFIX + '/api/xppage/all?projectId=' + config.PROJECT_ID, {
    method: 'GET',
    headers: {
      Authorization
    }
  })
}

function updatePage(pageId, methods) {
  return fetch(config.URL_PREFIX + '/api/xppage/update', {
    method: 'POST',
    headers: {
      Authorization,
      'Content-Type': 'application/json;charset=UTF-8',
    },
    body: JSON.stringify({ pageId, methods })
  })
}

module.exports = {
  async startTransform() {
    const err = []
    log(chalk.hex('#DEADED').bold('START TRANSFORM'))
    const response = await getPages()
    const resJson = await response.json()
    const pages = resJson.data
    const length = pages.length
    let done = 0
    // 用于显示进度
    spinner = ora(chalk.hex('#DEADED').bold(`ALL:${length} ------- DONE:${done}`)).start()
    for (let i = 0; i < length; i++) {
      const { functionCode, pageId, pageCode } = pages[i]
      if (!functionCode) { // 没有编写代码,直接跳过
        done += 1
        // 更新进度
        spinner.text = chalk.hex('#DEADED').bold(`ALL:${length} ------- DONE:${done}`)
        continue
      }
      try {
        const aftertrans = functionTransForm(functionCode)
        const res = await updatePage(pageId, aftertrans)
        const json = await res.json()
        if (json.code === '0') { // 转换成功一条
          done += 1
          // 更新进度
          spinner.text = chalk.hex('#DEADED').bold(`ALL:${length} ------- DONE:${done}`)
        }
      } catch (error) {
        err.push({ pageCode, error }) // 记录失败的原因
        continue
      }
    }
    spinner.stop()
    log(chalk.green.bold(`success transfrm: ${done}`), chalk.red.bold(`error: ${err.length}`))
    if (err.length > 0) { // 打印失败的原因
      err.forEach(e => log(chalk.red.bold(e.pageCode), e.error.message))
    }
  },
  functionTransForm
}

handle.js逻辑大概如下:

  • getPages用于获取应用下的所有页面
  • updatePage用于转换成功后更新数据库,一条一条的更新
  • functionTransForm调用Babeltransform,其中visitor.js下是我们即将要实现的Babel插件
  • 导出startTransformfunctionTransForm

实现visitor.js

就照着列好的规则,一条一条实现就好了:

// 1. xp.xxx -> this.$xxx
// 2. ctx.get('xxx') -> this.$refs.xxx,这个要考虑以下情况:
//     -  ctx.get('xxx')
//     -  ctx.get(code)
//     -  ctx.get(codes[i])
// 3. ctx.data -> this
// 4. handleAddress(ctx) 如果调用的是最外层定义的function -> this.handleAddress()
// 5. handleAddress(ctx) 如果调用的是局部定义的function -> handleAddress()
// 6. const crud = ctx.get('crud'); crud.$doDelete(ctx); 
//    -> var crud = this.$refs.crud;this.$doDeleteCrud(arguments);
// 7. 去掉函数声明,和函数表达式中的第一个参数(如果是ctx,除了xp.templateFactory.xxx(ctx))
// 8. 保留的ctx参数,要替换为this

xp.xxx -> this.$xxx

因为xp.xxxMemberExpression类型,所以我们实现了一个以MemberExpression为名的函数,当遍历到xp.xxx都会调用下面的这个函数,判断如果满足条件就把xp改成this,看下面的例子你就明白了。 我们调试transform-test,就会执行test.js,尝试着转换以下代码,看是否像我们期望那样:

function save(ctx) {
  xp.message('hello')
}

我是怎么开发一个Babel插件来实现项目需求的?

执行完以后,可以看到xp.message已经被转换为this.$message

我是怎么开发一个Babel插件来实现项目需求的?

module.exports = {
  /**
   * 修改xp.xxx
   * @param {*} path
   */
  MemberExpression(path) {
    if (path.node.object.name === 'xp') {
      // xp.xxx
      const property = path.node.property.name
      if (!property.startsWith('$')) { // 如果不以$开头,就加上$
        path.node.property.name = '$' + property
      }
      delete path.node.object.name
      path.node.object.loc.identifierName = undefined
      path.node.object.type = 'ThisExpression'
    }
  }
}

ctx.get('xxx') -> this.$refs.xxx

这个还是MemberExpression类型,所以我们接着上面的逻辑写,加了一个else if分支,其中又有三个分支,就代表三种情况:

  1. 第一个分支处理参数是字符串的情况(比如:ctx.get('absd')
  2. 第二个分支用于处理参数是MemberExpression类型(比如:const refs = ['absd']; ctx.get(refs[0])
  3. 第三个分支用于处理参数是Identifier类型(比如:const code = 'absd'; ctx.get(code)

以上三种情况结构差异还挺大的,所以要分开处理。

 else if (path.node.object.name === 'ctx') {
  /**
   * ctx.get()
   * @param {*} path
   */
  const propertyName = path.node.property.name
  if (propertyName === 'get') {
    const firstArgu = path.parent.arguments[0]
    if (firstArgu.type === 'StringLiteral') {
      // ctx.get('xxx')
      path.node.object.type = 'ThisExpression'
      path.node.object.loc.identifierName = undefined
      delete path.node.object.name
      path.node.property = {
        type: 'Identifier',
        loc: {
          identifierName: '$refs'
        },
        name: '$refs'
      }
      path.parent.type = 'MemberExpression'
      path.parent.object = path.node
      path.parent.property = {
        type: 'Identifier',
        loc: {
          start: {},
          end: {},
          identifierName: firstArgu.value
        },
        name: firstArgu.value
      }
      delete path.parent.arguments
      delete path.parent.callee
    } else if (firstArgu.type === 'MemberExpression') {
      // ctx.get(refs[i])
      path.node.object.loc.identifierName = undefined
      path.node.object.type = 'MemberExpression'
      delete path.node.object.name
      path.node.object.object = {
        type: 'ThisExpression',
        loc: {
          start: {},
          end: {}
        }
      }
      path.node.object.property = {
        type: 'Identifier',
        loc: {
          identifierName: '$refs'
        },
        name: '$refs'
      }
      path.node.property = firstArgu
      path.parent.type = 'MemberExpression'
      path.parent.object = path.node.object
      path.parent.property = path.node.property
      path.parent.computed = true
      delete path.parent.arguments
      delete path.parent.callee
    } else if (firstArgu.type === 'Identifier') {
      //const code = 'absd' ctx.get(code)
      path.node.object.type = 'ThisExpression'
      path.node.object.loc.identifierName = undefined
      delete path.node.object.name
      path.node.property = {
        type: 'Identifier',
        loc: {
          identifierName: '$refs'
        },
        name: '$refs'
      }
      path.parent.type = 'MemberExpression'
      path.parent.object = path.node
      path.parent.property = firstArgu
      path.parent.computed = true
      delete path.parent.arguments
      delete path.parent.callee
    }
  }

测试以下好不好用:

function save(ctx) {
  xp.message('hello')
  
  ctx.get('absd') // 第1种情况
  
  const refs = ['absd'] 
  ctx.get(refs[0]) // 第2种情况
 
  const code = 'absd'
  ctx.get(code)  // 第3种情况
}

转换过后,是符合预期的,如下图: 我是怎么开发一个Babel插件来实现项目需求的?

ctx.data -> this

这个就比较简单了,还是MemberExpression类型,还是在上面的基础上添加上一个else if分支,完整的:

我是怎么开发一个Babel插件来实现项目需求的?

 else if (propertyName === 'data') {
    // ctx.data
    path.node.type = 'ThisExpression'
    delete path.node.object
    delete path.node.property
 }

测试一下:

我是怎么开发一个Babel插件来实现项目需求的?

function xxx(ctx) {...} -> function xxx() {...}

这个就要重新写个函数了, 也非常简单,一看就懂:

/**
* 如果写了第一个参数为ctx,去掉
* @param {*} path
*/
FunctionDeclaration(path) {
    const first = path.node.params[0]
    if (first && first.name === 'ctx') {
      path.node.params.splice(0, 1)
    }
},

function(ctx){} -> ()=>{}

为了让代码看起来更简洁,我们统一把回调函数和函数表达式替换成箭头函数,并且去掉第一个参数(如果是ctx):

  /**
   * function() {} 转成 () => {}
   * @param {*} path
   */
  FunctionExpression(path) {
    path.node.type = 'ArrowFunctionExpression'
    spliceFunctionExpressionFirstArguCtx(path)
  },
  ArrowFunctionExpression(path) { // 代码写的如果就是箭头函数,直接去掉第一个参数(如果是`ctx`)
    spliceFunctionExpressionFirstArguCtx(path)
  },

spliceFunctionExpressionFirstArguCtx是定义在与module.exports同一级的,不是导出的内容。

// 去掉函数表达式的第一个参数(如果是ctx)
function spliceFunctionExpressionFirstArguCtx(path) {
  const argus = path.node.params
  if (argus.length && argus[0] && argus[0].type === 'Identifier' && argus[0].name === 'ctx') {
    path.node.params.splice(0, 1)
  }
}

验证一下: 我是怎么开发一个Babel插件来实现项目需求的?

修改没有被去掉的参数ctx为this

/**
* 修改参数中的 ctx 为 this
* @param {*} path
*/
Identifier(path) {
if (path.node.name === 'ctx') {
  path.node.name = 'this'
}
},

列出的需求中,xp.templateFactory.xxx(ctx)这种情况,第一个参数ctx不需要去掉,并且要将ctx替换为this,转换以下代码看下:

xp.templateFactory.xxx(ctx)

我是怎么开发一个Babel插件来实现项目需求的?

aaa(xxx) -> this.aaa(xxx)

这个需求是要看情况的,只要当这个aaa是最外层的函数声明,才需要这样处理,如果是局部的一个变量则不需要处理,举个🌰:

function a() {

}
function b() {
    a(ctx) // 需要处理
    const c = function() {}
    c() // 不用管
}

类似a()这样的属于CallExpression类型,因此我们新加一个以CallExpression为名的函数来处理:

/**
* 处理函数调用 比如 aaa(xxx) 转化为 this.aaa(xxx)
* @param {} path
*/
CallExpression(path) {
if (path.node.callee.type === 'Identifier') {
  // 替换sss(ctx) => sss()
  const argus = path.node.arguments
  // 如果调用外层的函数,是肯定有 ctx参数的,如果没有代表是函数体内的函数变量,不需要加this,不需要处理参数
  if (argus.length > 0 && argus[0].type === 'Identifier' && argus[0].name === 'ctx') {
    spliceOrReplaceExpressionFirstArguCtx(path)
    path.node.callee.property = { ...path.node.callee }
    path.node.callee.object = {
      type: 'ThisExpression',
      loc: {
        start: {},
        end: {}
      }
    }
    path.node.callee.type = 'MemberExpression'
    if (path.parent.type === 'ExpressionStatement') {
      // sss(ctx)
      path.parent.expression = path.node
    }
  }
}

spliceOrReplaceExpressionFirstArguCtx的作用还是去掉参数中的第一个ctx,也是定义在与module.exports同一级的,不是导出的内容。

function spliceOrReplaceExpressionFirstArguCtx(path) {
  // xp.templateFactory.xxx(ctx) 排除这种情况
  if (path.parent.type === 'ExpressionStatement' && path.parent.expression) {
    if (path.parent.expression.callee && path.parent.expression.callee.object) {
      const { object, property } = path.parent.expression.callee.object
      if (object && property && object.name === 'xp' && property.name === 'templateFactory') {
        return
      }
    }
  }
  const argus = path.node.arguments
  if (argus.length && argus[0] && argus[0].type === 'Identifier' && argus[0].name === 'ctx') {
  // 这是一个新需求,如果调用这几个函数,要把第一个参数转换为arguments,笔者也是很不理解,怎么会有这样的需求
    if(path.node.callee && path.node.callee.property && ['$openAdd', '$openUpdate', '$openView', '$doDelete'].includes(path.node.callee.property.name)) { 
      argus[0].name = 'arguments'
    } else {
      path.node.arguments.splice(0, 1)
    }
  }
}

验证一下:

我是怎么开发一个Babel插件来实现项目需求的? 好了,接下来只剩下最后一个需求了。

ctx.get('crud').$xxx -> this.$xxxCrud

感觉是在难为我啊,这转换之前,转换之后八竿子打不着,但没办法。这个需求主要难点就是如何获取get的参数,因为别人完全有可能这么写:

const crud = ctx.get('crud')
crud.$xxx

所以至少得考虑这两种情况,接着上面的CallExpression写就好了:

else if (path.node.callee.type === 'MemberExpression') {
      if (path.node.callee && path.node.callee.object) {
        const { object, property } = path.node.callee.object
        if (object && property && object.name === 'xp' && property.name === 'templateFactory') {
          return
        }
      }
      // ctx.get('crud').$xxx()
      spliceOrReplaceExpressionFirstArguCtx(path)
      if (
        path.node.callee.property &&
        path.node.callee.property.type === 'Identifier' &&
        path.node.callee.property.name.startsWith('$') &&
        path.node.callee.property.name !== '$nextTick'
      ) {
        try {
          // ctx.get('crud').$xxx()
          const code = path.node.callee.object.arguments[0].value
          path.node.callee.property.name = path.node.callee.property.name + upperFirst(code)
          path.node.callee.object = {
            type: 'ThisExpression',
            loc: {
              start: {},
              end: {}
            }
          }
        } catch (error1) {
          try {
            // const crud = ctx.get('crud')
            // crud.$xxx()
            const { name, type } = path.node.callee.object
            if(name && type === 'Identifier') {
              const varible = path.parentPath.parent.body.find(item => item.type === 'VariableDeclaration' && item.declarations && item.declarations[0] && item.declarations[0].id && item.declarations[0].id.name === name)
              path.node.callee.object = {
                type: 'ThisExpression',
                loc: {
                  start: {},
                  end: {}
                }
              }
              path.node.callee.property.name = path.node.callee.property.name + upperFirst(varible.declarations[0].id.name)
            }
          } catch (error2) {}
        }
      }
    }

上面的代码,笔者就分别实现了两种写法对应得转换逻辑,通过try catch能很好地实现这个需求,假设是第一种,如果不是肯定会报错,那么再尝试第二种。

测试一下: 我是怎么开发一个Babel插件来实现项目需求的?

有意思的需求

差点搞忘了一个需求,编写代码的时候是写的一个一个的function(对代码做了校验,最外层只能写function,连定义变量都不能),但是我们转出来的代码要直接放到Vuemethods选项中,还要加,

function a() {
}
function b() {
}

// 转换为:

a() {
},
b() {
}

对于这样的需求,笔者不知道用AST 语法树怎么实现,所以在handle.js中用正则匹配实现: 我是怎么开发一个Babel插件来实现项目需求的? 就是把第一个以外的function -> ,,第一个直接替换为空,这样做要保证代码中除了最外层的函数声明没有function(笔者已经把函数表达式转换为箭头函数了)。如果各位大佬有更好的方案,还望不吝赐教🌹

批量转换效果

我是怎么开发一个Babel插件来实现项目需求的?

写在最后

笔者通过本文记录了“我是怎么开发一个Babel插件来实现项目需求的”,阅读完的掘友对Babel的插件有了一个大致的了解,知道插件怎么写,大致怎么工作的,今后如果在工作中遇到类似需求的时候能够有个印象。另外,也很期待各位的意见,假设你来做这个需求,你会怎么做,是不是比笔者做的更完美,更优雅!欢迎在评论区交流。