gogocode是什么
贴一个官方的介绍在这边:
- 全网最简单易上手,可读性最强的AST处理工具!
- 如果你需要对代码升级、改造、分析,任何可以通过AST进行的处理,都可以用GOGOCODE快速解决问题。
- 不需要traverse,像剥洋葱一样一层一层的对比操作、构造ast节点,甚至不需要理解什么是CallExpression、Identifier、ImportDeclaration
函数插桩是什么
插桩:目标程序代码中某些位置插入或修改成一些代码,从而在目标程序运行过程中获取某些程序状态并加以分析。简单来说就是在代码中插入代码。 那么函数插桩,便是在函数中插入或修改代码。在实现的过程中,我们先进行引入,然后在各种类型的函数体内进行运行。
今天我们要通过gogocode去尝试在一个简易情境下实现插桩。主要是展示gogocode相比babel与jscodeshift的一些区别,与比较有特点的api。
实现步骤
- 首先准备一个js文件作为目标,使用gogocode引入。
- 遍历import块,查询是否引入过函数包。如果没有引入,则生成代码块进行引入。
- 确定要调用插桩函数的函数体类型,并根据类型进行调用插桩函数
前置内容
- 所有内容将使用typescript。
- 应当有ast基础。但gogocode的介绍说不认识相关类型也可以使用,大家可以试试。
准备目标文件并引入
目标文件:
// source.js
import ff from 'aa';
import * as bb from 'bb';
import {cc} from 'cc';
import 'dd';
const x = 1;
function a () {
console.log('aaa');
}
class B {
bb() {
return 'bbb';
}
}
const c = () => x;
const d = function () {
console.log('ddd');
}
这里使用的是神光大大在babel小册中的例子,小册写的很良心,内容丰富,希望大家多多支持~
引入文件:
//index.ts
import $ from "gogocode"
$.loadFile("./source.js")
loadFile
是对fs.readFileSync
的一层包装。使用这种方式引入文件直接生成gogoAST,方便我们接下来操作。
获取ast类型
接下来我们开始面对代码AST操作。
由于gogocode本身并没有暴露可使用的类型集,所以我们需要使用returnType
方法拿到gogoAST的类型:
import $ from "gogocode"
export type AstType = ReturnType<typeof $>
之后我们会使用很多次这个类型规范函数的入参。
查询是否引入过插桩函数
这里我们模拟插桩函数为track,使用gogocode进行查询:
/**
* 判断是否有import过track包
*/
export function haveImport(code: AstType): boolean {
let flag = false
code
.find(`import $_$1 from "$_$2"`)
.each(item => {
if(item.match[2][0].value === "track") {
flag = true
}
})
return flag
}
这里我们使用了find
方法,find方法接收一个字符串,字符串内可以使用_或者$$$占位符对不确定的部分进行占位。这里我要查询import部分,不确定代码内引入了什么并命名了什么,所以分别用_1和_2进行占位。
find
方法会返回一个结果数组,可以使用方法each
对结果进行遍历。由于我们使用了占位符,所以结果会带有match属性,属性下是占位符部分的内容。_后的内容就是match内的key。
比如这里我们要确定文件有没有引入track,所以需要判断_2 所匹配的内容是否等于track。
item.match[2][0].value === "track"
这里存在一个问题,数字的语义化会影响代码的可读性。使用数字作为key可能不满足你的需求,这时也可以使用字符
find(`import $_$name from "$_$source"`)
这样我们使用的时候用字符作为key即可
item.match["source"][0].value === "track"
如果这时你使用的是typescript,你会遇到新的问题。类型规定了match的key必须为number。目前的替代方案是在前面加上ts忽略标签。等待官方修复吧~
在我们的例子代码中是没有引入track包的,接下来我们进行添加引用。
对没有引入的目标文件进行引入
引入应当是在所有内容之前,也就是置顶的位置,我们可以通过这种方式直接插入:
/**
* 引入track包
*/
export function insertTrack(code: AstType) :AstType {
return code
.before(`import track from "track"; \n`) // 可爱的换行符不要忘记
}
但这也不太能满足我们的要求,一般插桩函数的命名都会给一个独一无二的,方便检索。这里我们使用一个函数生成递增的uid。当然这是很不严格的,生产环境下应当使用其他方式生成。
/**
* 返回uid
*/
export const generateUid = function (){
let uid = 0
function add() {
uid++
return uid
}
function get() {
return uid
}
return {
add,
get
}
}()
接着我们使用的uid和track进行拼接:
export function insertTrack(code: AstType) :AstType {
const trackName = `track_${generateUid.add()}`
return code
.before(`import ${trackName} from "track"; \n`)
}
这样我们生成出的置顶代码差不多是这个样子:
import track_1 from "track"
函数已经引入,但在插桩之前,我们还需要解决一个小问题。
转化箭头函数为具有return的函数
我们的例子中有一个这样的函数:
const c = () => x;
我们要在里面添加这样的代码
track();
那么必须先转化为这样
const c = () => {
// 要在这里插入代码
return x;
}
所以我们写一个转化函数的方法
/**
* 箭头函数转为命名函数
*/
export function turnArrowToAnonymous(code: AstType): AstType {
return code
.find(`const $_$1 = () => "$_$2"`) // 这里必须加"",不然会解析错误
.each(node => {
if(node.has(`() => $_$`)) { // 做二次判断,筛掉命名函数
node.replace(
`const $_$1 = () => "$_$2"`,
`const $_$1 = function() { return "$_$2"; } \n`
)
}
})
.root()
}
细心的朋友已经发现了,这个地方我们使用了一个新的api, has
。这个api帮助我们判断find找出的函数中是否具有标识类型的部分。
那么问题在于,为什么要进行第二次的判断呢?const $_$1 = () => "$_$2"
部分应该已经筛选出箭头函数了呀?
但其实这种方式进行筛选,会把这类也筛进来
const d = function () {
console.log('ddd');
}
这里就要说一下find
方法实现,虽然我们输入的是一个模板字符串,但gogocode不会用它直接去做正则的匹配(当然不会)。gogocode会把这些转化为具体输入的类型,再进行匹配,我猜可能是这个地方出现了某种重叠。
经历了两次筛选,我们终于找到了要找的箭头函数。接着我们进行替换,这里使用了replace方法,使用对应的模板,并改一下结构就可以,这里还是很方便的。
但要注意,格式务必正确。相对于ast不合法的代码将在编译过程中抛错。
那么插播一下,什么叫“相对于ast不合法”呢?很简单,把你的代码贴到https://astexplorer.net/
上面去看能不能识别出ast树,如果识别不出,那相对于ast就是不合法的。
现在我们的函数都转化为可以被插桩的函数了,下面我们开始插桩。
插桩
在插桩之前,我们需要先确定函数类型。虽然函数在我们眼里都是函数,但是在ast树内就是完全不同的一些东西。
// FunctionDeclaration
function a () {
console.log('aaa');
}
// FunctionExpression
const d = function () {
console.log('ddd');
}
class B {
bb() {
return 'bbb';
}
}
FunctionDeclaration
这种是最规范的函数,我们也最喜欢。
export function insertFunc(code: AstType):AstType {
return code
.find(`function $_$ (){}`).each(node => {
node.prepend("body",`track_${generateUid.get()}(); \n`)
})
.root()
}
这里我们使用了prepend,这个方法要求我们传入一个数组形式的ast节点,然后它会把代码添加到这个数组的最顶端。
FunctionExpression
这种相对于Declaration类型,它没有名字,它的命名在函数之外。但名字和我们关系不大,我们对函数体内的部分进行操作即可
export function insertAnonymousFunc(code: AstType) {
return code
.find(`
const $_$1 = $_$body
`)
.each(funcNode => {
// @ts-ignore
const bodyAst = $(funcNode.match["body"][0].node).find(`{}`) // 找到函数体
bodyAst.prepend("body",`track_${generateUid.get()}(); \n`)
})
.root()
}
至此我们就对目标文件转换完成啦。
小结
总的来说是一个想法很好的工具,针对于很多codeMod工具进行了封装。对于简单的代码转化需求已经是绰绰有余,如果你有需要,看10分钟的文档即可上手。但这里还是有一些小小问题,希望有能力的大大佬抽空支援一下。
-
ts支持度的问题。首先没有像jscodeshift一样开箱即用的类型声明,需要用户自己推导。并且在match方面的类型处理是有些问题的。
-
使用模板字符串的方式进行查询,方向是好的。极大方便了操作,但又出现了另一种问题,字符串的书写并不是完全自由,刚上手的小伙伴可能会这样使用
find(`$_$1 $_$2 = () => {}`)
因为你不确定变量是用什么方式声明的,并且被声明成了什么。所以你选择用两个占位符。但这样会报错。因为它不符合ast的直觉书写。所以想熟练使用此类工具的小伙伴还是要打好ast基础哦。