关于babel
babel主要用途有三个:
将esnext、typescript等语法转译为目标环境所支持的javascript。
babel由于可以生成AST,因此我们可以在AST上进行各种各样的检查以及操作,比如lint以及压缩、混淆。
同第二点,babel也暴露了一些api,通过这些api,我们可以进行额外的操作,比如自动国际化以及代码插桩埋点
一、为什么
项目代码中进行埋点与插桩,是针对项目分析重要一环,通过埋点与插桩,可以知晓项目在生产环境的性能表现,以及某些功能的使用频率,所以,可以有针对性的开发与优化,而这些优化增加了客户体验。如果纯手动进行埋点插桩,那么对整个项目代码侵入是非常严重的,从而导致项目很难维护,行程了负反馈,那么需要一种对源码几乎无痛的注入方式,比如AST注入。
二、思路
以下是一个简单的 vue 方法:
我们想要知道这个方法同一个用户一分钟内调用次数、参数是什么、有没有报错,如果没有babel,那么我们会在函数头部插入一个全局方法,自动获取参数以及单位时间统计数据。
比如这样:
在vue挂载一个全局方法 $globalSetData,这个方法用于接收埋点的函数名、触发的函数时候的参数数组、以及触发的时候时间戳。
注:$globalSetData 方法的实现逻辑可以自行发挥,毕竟所有埋点都会执行这个方法,那么这个方法是把这些数据储存在本地、或者上传到数据分析平台都是可以的。 因此本文章假定,执行这个方法就视为埋点执行成功。
如果一个一个函数、方法进行添加实在是太过麻烦,也让代码不好维护。
但是,如果我在函数、方法的块级注释里面,增加 setTrack 标识,当有这个标识的函数、方法自动在头部注入埋点。
题外话:
其实本想更加复杂一些:给函数开始与每一个结束点都注入时间戳,并上传两个变量相减的结果,用来分析每个函数的执行时间,但发现其实没有必要。
原因是:
有些函数是async await用于发接口,因此函数执行时间与代码没有太大的关系,即使是Promise.all 优化过,那么接口时间依然远远大于函数处理时间,因此此统计没有意义。
有些函数结束时机取决于if 等条件选择,因此此统计没有意义。
函数执行时间远远小于毫秒级,即使出现毫秒,也是比接口小于一个数量级,因此此统计没有意义
综上所述,此注入没有意义。
三、必要学习的前置
babel的编译流程分为三部:
parse: 将源码转换成AST语法树
transform: 调用各种transform插件对AST语法树进行增删改
generate: 根据修改后的AST语法树生成代码,并生成sourcemap
其中 babel 7将这三个阶段分为了大概三个包:
@babel/parser 解析源码成 AST,对应 parse 阶段
@babel/traverse 遍历 AST 并调用 visitor 函数,对应 transform 阶段
@babel/generate 打印 AST,生成目标代码和 sorucemap,对应 generate 阶段
由此而知,我们当前主要的操作是对AST语法树进行增加,也就是处于transform阶段
不过仅仅traverse是不够的,因为这次我们是通过插件形式进行AST修改,也就是说,我们要写一个visitor函数,同时,由于我们是修改AST,所以除非我们可以手写AST或者有外部AST传入,我们都得需要生成AST的包。比如:
@babel/types :创建、判断 AST
@babel/template :根据模块批量创建 AST
不过,这些包并不需要我们亲自安装引入,在插件的主函数入口,他们会作为参数传进来。
以下是一个babel插件框架,虽然也可以通过@babel/helper-plugin-utils 创建插件,但其内部只是对以下框架所接受的参数做一个校验与兼容,如果需要安装这个包的话,那么代价确实比较大,所以完全可以手写一个框架。
可以看到 types和template已经作为参数引入
如果不知道AST树是如何,或者不知道如何获取对应node和path,那么可以推荐一下网站,实时查看
四、第一种思路,由外向内
尽管我们的思路很明确,但实际情况看起来要严峻得多,因为,函数、方法有各种各样的形式:
那么他们的AST就有各种形式,这次我们就挑几种,所幸,babel允许传入多个type的visitor,用 | 进行分割
'ClassMethod|ArrowFunctionExpression|FunctionExpression|FunctionDeclaration....'(path,state){
...
}
以上类型分别对应:class方法,箭头函数,自执行函数以及function定义的函数
我们可以通过types的API来判断当前节点是否是我们想要插入的位置,如果是的话,我们就可以通过tempate的API把globalSetData的代码字符串转为AST,然后放到函数体的最上方。
其中:
types.isBlockStatement用来判断当前是否是块语句,由于visitor入口处已经判断了type,因此只需判断是否是块级即可。
但凡事总有例外,比如箭头函数,并没有块状语句,因此需要自己包装一下,然后用path.replaceWith直接把原函数体替换掉。
template.statement用于将单个代码转成AST,如果是复数的语句,那么需要用到template.statements,并且返回值也是数组,因此这里需要注意一下
template是可以支持占位符的,比如PREV_BODY就是一个占位符,这也导致了一个问题,需要检查插入的代码中有有无符合占位符的情况,否则将代码错误匹配为占位符,然后会报错。
template除了接受代码作为参数以外,还接受第二个参数,是一个配置项,其中与占位符有关的配置为一下三个
syntacticPlaceholders:是否可以使用%%test%%这种占位符,默认值为undefined
placeholderWhitelist:此选项接受一个Set<string>,由自动被识别为占位符的字符串组成。默认值是undefined。同时跟syntacticPlaceholders不兼容
placeholderPattern:此选项接受RegExp或者false,用来判断哪些字符串可被识别为占位符,如果是false就关闭识别占位符功能。默认值是/^[_$A-Z0-9]+$/,同时跟syntacticPlaceholders不兼容
搜索注释
到这一步,代码插入的逻辑已经很明朗了,但依旧不够完善,因为如果这么写的话,会把所有的方法、函数都插入埋点,这个不符合我们的需求,那么我们需要一种标识,来标记这个方法要不要埋点,比如注释。
注释有块级注释和行内注释两种,对于方法与函数来说,块级注释要比行内注释好得多
根据AST树得知,块级注释类型为leadingComments,大多数方法和函数都有个属性,都在他们的AST节点下,可以用path直接获取,但函数表达式和箭头函数的leadingComments不在他们的节点下面,而是在他们的父节点。
getComments方法是用来寻找commentNode里面有无符合标识的注释,如果当前方法或者函数没有注释或者注释里面没有对应标识,那么显然不是我们埋点的目标,直接return即可
以下为搜索注释函数的简易处理方法,实际上是搜索字符串,如果优化的话,可以用split等进行分割处理。
globalSetData
回过头看看,我们的插入代码已经很完善了,接下来讲讲globalSetData的实现
首先,globalSetData 就是 this.$globalSetData的代码字符串,但这个代码接受三个参数,分别为函数名称、参数数组、以及时间戳。
时间戳我们可以直接写在字符串中。但函数名称和参数是我们需要在AST中获取到的。
好消息是,我们进行到了最后一步,只要完善了globalSetData,我们的埋点插件就完善了。
坏消息是,我们要面对更多不同结构的AST,从里面获取他们的函数名称以及参数数组
函数名称
根据本大节一开始,我们就罗列了不少函数的定义方式,也代表着他们有着不同的AST,也意味着,他们的函数名称存储在不同地方。
最常见的是FunctionDeclaration,即function定义的函数,他们的函数名称存在 path.node.id.name下,也就是说当前节点的id属性存储着当前函数名称信息
但如果是ArrowFunctionExpression,也就是箭头函数的话,他们一般是把自己赋值给另一个变量,因此他们的id是null,需要到他们的父节点去查找对应id,也就是path.parent.id.name
但是还有其他情况,比如在对象中的函数,他们自己没有id,父节点也没有id,取而代之的是key,所以如果父节点没有id的话,需要判断key里面是否有对应的函数名称信息,也就是path.parent.key.name
还有一种情况,是ClassMethod,他们本身类似对象中的父节点,因此他们自身节点的key里面存储着函数名称信息,也就是path.node.key.name
如果按照以上顺序进行判断,即可筛选出大部分函数名称,然后保存成变量,填充到this.$globalSetData
函数参数
两个消息,好消息是函数参数不会乱跑,基本都在node.params下,params是一个数组
坏消息是,参数分为很多种类,比如数组解构、对象解构、扩展运算符、一般参数
以第一个参数为例:
如果是一般参数,那么它的type就是Identifier,而它的参数就是跟type同级的name,那么它就是path.node.params[0].name
如果是对象解构,那么它的type就是ObjectProperty,而它的参数需要在properties中遍历获取,每项的key.name就是对应参数,也就是path.node.params[0].properties[0].key.name
如果是数组解构,那么它的type就是ArrayPattern,而它的参数需要在elements中遍历获取,每项的name就是对应参数,也就是path.node.params[0].elements[0].name
如果是扩展运算符,那么它的type就是typeRestElement,而它的参数需要在argument.name中获取,也就是path.node.params[0].argument.name
至此,我们已经获取到了函数名称和函数参数,然后将它们拼到globalSetData中
其中functionName就我们在上个步骤获取到的函数名称,paramsArray是我们获取到的函数参数
那么,我们这第一种埋点思路就完成了。
demo
const parser = require("@babel/parser")
const traverse = require("@babel/traverse").default
const generate = require("@babel/generator").default
const template = require("@babel/template").default
function getComments(commentNode) {
// 块级类型注释给予埋点处理
if (commentNode.type === "CommentBlock") {
// 提取注释
return commentNode.value.includes("setTrack")
}
return false
}
const sourceCode = `
/**
* setTrack
*/
function test (a) {
return a + 1;
}
function test2 (a) {
return a + 1;
}
`
const ast = parser.parse(sourceCode, {
sourceType: "unambiguous",
})
traverse(ast, {
FunctionDeclaration(path) {
const comment = path.get("leadingComments")[0] || {}
let commentNode = comment.node
if (!commentNode) {
// 寻找找父节点
path.findParent((parentPath) => {
const comment = parentPath.node.leadingComments
if (!comment) {
return false
}
commentNode = comment[0] || {}
return true
})
}
if (!commentNode || !getComments(commentNode)) return
const node = path.node
const body = node.body.body
const functionName = node.id.name
const params = `[${node.params.map((item) => item.name).join(",")}]`
const globalSetData = `this.$globalSetData('${functionName}',${params},new Date().getTime())`
body.unshift(template.statement(globalSetData)())
},
})
const { code } = generate(ast, {
sourceType: "unambiguous",
})
console.log(code)
五、第二种思路,由内向外
这个思路大体与第一种一样,但visitor寻找的并非function相关的type,而是Identifier,通过Identifier来判断父节点是否是function相关的type,如果是的话就执行对应操作。
如何判断是否是function相关的type呢?那就用到我们第一种方法没有用到types包了
types.is[type]是types包内部实现的方法,可以使用函数式判断,此type是否是我们想要的结果
通过此方法可以获取到body,同时可以进行针对body的操作了。
这种方式的优点是:可以很方便的获取函数相关信息,比如函数名与函数参数
但缺点也很明显,Identifier 导致几乎所有变量都会访问,增加了很多不必要的逻辑判断,同时内部也需要types包进行层层判断。
六、babel-laoder缓存
在开发过程中,由于babel-loader存在缓存,所以并不是每次插件都会实时生效的,因此我们可以增加一个webpack插件,在构建的时候,删除/node_modules/.cache/babel-loader文件夹,来达到清空缓存的效果
比如我们现在已经有了一个小插件
我们只需在vue.config.js里面配置一下,就可以进行启用了
七、结束
最后,babel插件写完,记得在babel.config.js中的plugins进行引用,plugins接受一个二位数组,最内层的数组第一项是插件本身,第二项是插件所需参数,可以增加更多个性化设置.