基于babel的埋点工具简单实现及思考

2,510 阅读9分钟

大家好,我是王大傻,最近阅读了光哥的Babel通关秘籍小册,也算对AST有了一点的理解,所以基于小册中的插入函数调用参数自动埋点两个章节,结合自身的一些思考,写了一个基于Babel自动埋点,传参的小玩具,希望大家看完后也有自己的理解,有什么不对的地方欢迎大家指正,也可以看看光哥(神说要有光)的相关文章来熟练理解下。(计划着呢是春节写完这篇文章的,不好意思久等了哈)

相关知识点

  1. 什么是AST抽象语法树
    1. 程序的编译过程
  2. AST的用途
  3. Babel的原理
  4. 个人实现的基于babel的埋点实例及思考

image.png

什么是AST抽象语法树

程序的编译过程

什么是程序的编译呢?我们都知道,在传统的编译语言流程中,程序中的一段代码在它被执行之前都会经历三个步骤,这个步骤的执行过程也就是程序的编译过程。

  1. 分词(词法分析) 词法分析的过程也就是第一步,我们写的代码本质上就是一串串字符串,而词法分析这个过程则会把这些由字符组成的字符串去分解成有意义的代码块。比如:
 let a = 1
 // let   a   =   1

在这个程序中就会把 let、 a、 =、 1、 拆分开来,对于某些特殊占位符(如空格)是否需要拆分则会取决于这个占位符是否有实际的意义。

  1. 解析(语法分析) 语法分析的过程就是将词法分析后的结果按照一定的规则进行组合,将散列的代码块进行关联并形成一个代表程序语法结构的树,也被称为是抽象语法树(AST)。抽象语法树是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码中的一种结构,之所以说是抽象的,抽象表示把js代码进行了结构化的转化,转化为一种数据结构。这种数据结构其实就是一个大的json对象,json我们都熟悉,他就像一颗枝繁叶茂的树。有树根,有树干,有树枝,有树叶,无论多小多大,都是一棵完整的树。简单理解,就是把我们写的代码按照一定的规则转换成一种树形结构如:

image.png 具体的ast内容大家也可以通过这里自行去输入查看,另外,对于这个工具来说 分别有选择语言以及拆解工具的地方,大家也可以根据自己的语言去选择相应的环境 image.png

image.png

  1. 代码生成 代码生成环节也是编译过程的最后一节,它会将语法分析阶段的AST抽象语法树转换为可执行的代码,然后在交换个我们。至于生成什么样的代码,也是可以由我们自己去决定的,理论上符合语言的规则就可以。

AST的用途

在了解了什么是AST之后,关于AST的用途有哪些,想必我们心中都有了一定的答案。AST的作用不仅仅是用来在JavaScript引擎的编译上,我们在实际的开发过程中也是经常使用的,比如我们常用的babel插件将 ES6转化成ES5、使用 UglifyJS来压缩代码 、css预处理器、开发WebPack插件、Vue-cli前端自动化工具等等,这些底层原理都是基于AST来实现的,AST能力十分强大, 能够帮助开发者理解JavaScript这门语言的精髓。有了这些,我们可以准确的操控代码的运行时以及编译时的相关处理。

例如:大傻之前在逛GIthub时候偶然发现了关于Vue3.x的issue。 具体情况是这样的,在使用Vue3.0时候,猛然间发现了如果使用jsx写法会导致有些例如v-once这些不支持,不支持怎么办呢?百度谷歌搜起来,搜完后觉得还没明白就来到了issue,这里有个思路,因为是jsx语法,所以我们去的肯定不是Vue的issue,肯定是转换工具的,在这里我们用的是 babel-plugin-jsx,并且发现了如下issue

image.png 在一番激烈的狡(交)辩(流)后,大傻输了,静下心发现了在代码编译的这两处(12)并没有对v-once等一些指令做相应的处理。

这个小例子,也说明了AST扮演的角色,比如我们在某些报错后怀疑是某个库或者框架的错误,其实也有可能是在编译阶段由于规则不一致或者没提供暴露的错误。如果我们能准确分析出来错误原因,那么妈妈再也不用担心我乱提issue了。

image.png

Babel的原理

随着前端工程化的兴起,让我们接触了更多的语言工具,babel在这就是一种特有的工具。

我们通常对babel的理解就是它可以帮助我们去处理兼容性,也就是有些JavaScript的新特性,可能我们想去使用,但对于某些浏览器来说还并未支持,此时我们就可以通过babel 将我们的代码降级处理为浏览器兼容的执行版本从而达到开发和生产环境两套代码,一次操作的便捷开发。

  • Babel插件就是作用于抽象语法树
  • Babel三个主要的处理步骤就是解析(parse)转换(transform)生成(generate)
  1. 解析 解析就相当于我们的编译过程中的词法分析和语法分析的结合版,将代码解析成抽象语法树(AST),每个js引擎(比如Chrome浏览器中的V8引擎)都有自己的AST解析器,而Babel是通过Babylon实现的。解析过程有两个阶段:词法分析和语法分析,词法分析阶段把字符串形式的代码转换为令牌(tokens)流,令牌类似于AST中节点;而语法分析阶段则会把一个令牌流转换成 AST的形式,同时这个阶段会把令牌中的信息转换成AST的表述结构。

  2. 转换 转换这个步骤一般来说就是暴漏给我们的处理步骤,将在此阶段对节点进行添加、更新以及移除操作。通过traverse进行深度优先遍历,维护AST树的整体状态,并且可完成对其的替换、删除或者增加节点。返回的结果就是我们处理后的AST。

  3. 生成 生成阶段就是将我们二阶段的最终AST进行转换,转换成我们的字符串形式的代码,并且创建代码映射,也就是source-map。代码生成就是,先对整个AST进行深度遍历,再通过generate转换为可以表示转换后代码的字符串。 image.png

个人实现的基于babel的埋点实例及思考

我们一般埋点时候都是通过函数的形式,传入指定参数进而实现埋点,那么我们在开发过程中如果对需要埋点的地方给一些特殊标识(在这我用的是console.log),那么当我们代码在执行前是不是可以通过工具去批量化的处理这些埋点的地方进而实现统一埋点.整个流程建议大家参考前面的ast生成器网站去边看边写

首先是tacker.js,这个文件主要就是对我们源代码生成AST后的AST进行处理,主要两个方面

  • 在此模块中导入我们的埋点函数
  • 遍历查找我们的标识区域进行替换操作
const { declare } = require('@babel/helper-plugin-utils');
const importModule = require('@babel/helper-module-imports');
const {default: template} = require("@babel/template");

const autoTrackPlugin = declare((api, options, dirname) => {
  api.assertVersion(7); // 表示是版本7

  return {
    visitor: {
      Program: {
        enter (path, state) {
          path.traverse({
            ImportDeclaration (curPath) {
              const requirePath = curPath.get('source').node.value;
              if (requirePath === options.trackerPath) {
                const specifierPath = curPath.get('specifiers.0');
                if (specifierPath.isImportSpecifier()) {
                  state.trackerImportId = specifierPath.toString();
                } else if(specifierPath.isImportNamespaceSpecifier()) {
                  state.trackerImportId = specifierPath.get('local').toString();
                }
                path.stop();
              }
            }
          });
          if (!state.trackerImportId) {
            state.trackerImportId  = importModule.addDefault(path, 'tracker',{
              nameHint: path.scope.generateUid('tracker')
            }).name;
          }
        }
      },
      'ClassMethod|ArrowFunctionExpression|FunctionExpression|FunctionDeclaration'(path, state) //关于这块知识 大家可以看下官方文档把 比较多 这是为了找符合函数的AST节点{
        const targetCalleeName = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`);
        // TODO 找子节点
        const bodyPath = path.get('body');
        if (bodyPath.isBlockStatement()) {// 先去找块级的作用域
          const bodyPath2 = bodyPath.get('body.0') // 目前找的是第一个块级的 body内容
          console.log(bodyPath.get('body').type)
          if(bodyPath2.isExpressionStatement()){// 这个找的是console对应的ast语句 
            const calleeName = bodyPath2.get('expression').get('callee').toString()//
            const bodyPath3 = bodyPath2.get('expression')
              if (targetCalleeName.includes(calleeName)) {
                let arg = []
                bodyPath3.node.arguments.forEach((item,index,array)=>{
                  if(array[0].value==='tracker'){
                  //  如果我们console的第一个值为tracker时候 说明是埋点 否则就是我们普通的一个console.log
                    if(index>0){
                      let ret = item.value || item.name
                      arg.push(ret)
                    }
                  }
                })
                if(arg.length>0){
                  state.trackerAST = template.expression(`${state.trackerImportId}(${arg.join(',')})`)();
                  bodyPath3.remove()// 移除原来的console代码
                  bodyPath.node.body.unshift(state.trackerAST);// 插入最新的我们自己的代码
                }
              }

          }
        }
      }
    }
  }
});
module.exports = autoTrackPlugin;

然后是我们的startTracker.js,这个文件就是我们的入口函数,我们在本地测试时候可以通过 node startTracker.js指令去运行这段代码,它的主要作用就是,转化为AST交给我们tracker函数去处理AST,拿到处理后的AST并且生成新的代码

const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const autoTrackPlugin = require('./tracker');
const fs = require('fs');
const path = require('path');

const sourceCode = fs.readFileSync(path.join(__dirname, './code.js'), {
  encoding: 'utf-8'
});

const ast = parser.parse(sourceCode, {
  sourceType: 'unambiguous'
});

const { code } = transformFromAstSync(ast, sourceCode, {
  plugins: [[autoTrackPlugin, {
    trackerPath: 'tracker'
  }]]
  //
  /*
  * 调用函数转化
  * 1 传入ast 内容
  * 2 传入map ast错误问题映射到map文件中
  * 3 一个对象 是配置相关
  *   1 传入plugins 是一个数组  数组是不同的插件 也可以用数组标识
  *     插件的数组
  *       第一个是用的插件
  *       第二个是对这个插件提供的配置 可以自定义的常量 一并放进接收的options中
  * */
});

console.log(code);

最后就是我们的测试用的Code代码(目前只模拟做了一个块级作用域的内容,多个块级作用域并没有去写,大家可以看着AST自己完善下)

const obj={
  a:111
}
function a () {
  console.log(obj);
}

class B {
  bb() {
    console.log('tracker',232)
    return 'bbb';
  }
}

const c = () => 'ccc';

const d = function () {
  console.log('tracker','1818',11);
}

image.png 最后 通过运行我们可以看到输出后的结果以及和源代码的对比结果.怎么样?是不是感觉很有意思. 希望大家在看过文章后有一个初步的了解,也可以找一些资料来巩固下学习下.并做一些自己的小工具来增加印象, 最后祝大家新的一年里,工作生活都虎虎生威!!! (喜欢大傻的童鞋可以点个小心心加波关注,谢谢大家)

image.png