我正在参与掘金创作者训练营第6期,点击了解活动详情
前言
公司有一个通过可视化的配置来实现基础布局,常见的页面开发的低代码产品,比如表格、表单、树等等这类页面。也是基于组件化的一种开发理念,选中对应的组件时,可以进行对应的属性,事件的配置,以及个性化逻辑代码的编写。编辑代码的时候,可以调用封装的API和上下文对象实现对组件的状态更改,运行的时候就是读取数据库的页面配置动态渲染。
最近有一项新的研发任务,就是要把根据页面的配置数据生成页面对应的Vue
文件。那这样的话,编写的代码运行肯定就会有很多问题,为了保证转出来的代码风格与手动开发的尽量保持一致。所以需要对JavaScript
代码进行一些转换,同时还要做很多工作适配原来的API
。
先给大家看下设计页面:
代码编辑页面:
预览:
接下来呢,看下笔者要将用户写的
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 的三个主要处理步骤分别是: 解析(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...
了解完上面这些,我们就可以开始设计实现了,不知道各位掘友有没有这么一个习惯,就是在敲代码之前,要先弄清楚需求,然后在脑子里设计好了,这里的的设计就有很多学问了(实现的优不优雅,扩展性怎么样,可维护性怎么样......),做完这些咱们再去写代码也不迟。所谓磨刀不误砍柴工嘛。
设计实现
那我们先理理:
- 因为页面有多个,项目中页面是归属于应用,可以以应用为单位实现一个批量转换的脚本,调用一个命令全部转换
- 转换完需要更新数据库
- 抽取应用ID,请求地址,
token
的配置 - 实现代码转换
- 针对转换不成功的,要跳过并记录下失败原因
新建一个文件夹script
,用来放即将实现的脚本:
在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
调用Babel
的transform
,其中visitor.js
下是我们即将要实现的Babel插件- 导出
startTransform
和functionTransForm
实现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.xxx
是MemberExpression
类型,所以我们实现了一个以MemberExpression
为名的函数,当遍历到xp.xxx
都会调用下面的这个函数,判断如果满足条件就把xp
改成this
,看下面的例子你就明白了。
我们调试transform-test
,就会执行test.js
,尝试着转换以下代码,看是否像我们期望那样:
function save(ctx) {
xp.message('hello')
}
执行完以后,可以看到xp.message
已经被转换为this.$message
:
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
分支,其中又有三个分支,就代表三种情况:
- 第一个分支处理参数是字符串的情况(比如:
ctx.get('absd')
) - 第二个分支用于处理参数是
MemberExpression
类型(比如:const refs = ['absd']; ctx.get(refs[0])
) - 第三个分支用于处理参数是
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种情况
}
转换过后,是符合预期的,如下图:
ctx.data -> this
这个就比较简单了,还是MemberExpression
类型,还是在上面的基础上添加上一个else if
分支,完整的:
else if (propertyName === 'data') {
// ctx.data
path.node.type = 'ThisExpression'
delete path.node.object
delete path.node.property
}
测试一下:
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)
}
}
验证一下:
修改没有被去掉的参数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)
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)
}
}
}
验证一下:
好了,接下来只剩下最后一个需求了。
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
能很好地实现这个需求,假设是第一种,如果不是肯定会报错,那么再尝试第二种。
测试一下:
有意思的需求
差点搞忘了一个需求,编写代码的时候是写的一个一个的function
(对代码做了校验,最外层只能写function
,连定义变量都不能),但是我们转出来的代码要直接放到Vue
的methods
选项中,还要加,
:
function a() {
}
function b() {
}
// 转换为:
a() {
},
b() {
}
对于这样的需求,笔者不知道用AST 语法树怎么实现,所以在handle.js
中用正则匹配实现:
就是把第一个以外的
function
-> ,
,第一个直接替换为空,这样做要保证代码中除了最外层的函数声明没有function(笔者已经把函数表达式转换为箭头函数了)。如果各位大佬有更好的方案,还望不吝赐教🌹
批量转换效果
写在最后
笔者通过本文记录了“我是怎么开发一个Babel
插件来实现项目需求的”,阅读完的掘友对Babel
的插件有了一个大致的了解,知道插件怎么写,大致怎么工作的,今后如果在工作中遇到类似需求的时候能够有个印象。另外,也很期待各位的意见,假设你来做这个需求,你会怎么做,是不是比笔者做的更完美,更优雅!欢迎在评论区交流。