手撸一个babel自动埋点插件

2,091 阅读7分钟

「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战

关于Babel

Babel是一个Javascript的编译器,主要用于将es next(ECMScript2015,ECMScript2016,ECMScript2017等)的语法转换为向后兼容的JavaScript语法,使代码能够运行在当前的和旧版本的浏览器或其它环境中。

Babel编译流程

Babel编译流程分三步:

  1. parse: 将源码转换成AST语法树
  2. transform: 调用各种transform插件对AST语法树进行增删改
  3. generate: 根据修改后的AST语法树生成代码,并生成sourcemap

ee9eaa1f265c4c49ad156f2c691748d9_tplv-k3u1fbpfcp-watermark.webp

parse

parse的作用主要是将字符串形式的源码转换成AST语法树,这个过程有分为词法分析和语法分析。

词法分析主要是将字符串形式的源码解析为tokens,可以理解为将这样一段代码:

let a = 1

根据单词规则来拆分成最小形式的字符串(token):

leta=1

然后根据生成的token转换为对应语法结构的AST。

transform

transform会对生成的AST进行遍历,遍历过程中会调用节点中注册的visitor函数,visitor函数中可以对AST节点进行增删改,我们的插件也就是在这一步去完成相对应的功能的。

export default function (babel) {
  const { types: t } = babel;
  
  return {
    visitor: {
      Identifier(path) {
        // 对ast节点进行增删改操作
        path.node.name = path.node.name.split('').reverse().join('');
      }
    }
  };
}

generate

generate就是根据增删改后的AST生成最新的代码,并生成sourcemap。

埋点插件

实现插件前,我们先来确定插件的功能:

  1. 根据函数块级注释判断是否需要自动埋点
  2. 从注释中提取出埋点所需要的参数
  3. 引入埋点函数文件
  4. 函数插装:将埋点方法插入需要埋点的函数,并传入参数

首先,先写一个Babel插件的架子:

function autoTracker({types: t, template}) {
    return {
        visitor: { 
            
        }
    };
}

module.exports = autoTracker;

Babel插件其实也就是一个函数,它会将api通过参数的形式传回来。

它会返回一个对象,对象中有一个visitor属性,这个属性中声明的函数会在transform的过程中被调用。

既然我们需要根据函数的注释来判断是否需要自动埋点,那么首先我们得先确认,函数有哪些形式:

// 函数声明
function tracker() {}

// 函数表达式
const tracker = function () {}

// 箭头函数
const tracker = () => {}

// 类方法
class Test {
    tracker() {}
}

它们在AST中所对应的类型:

函数声明 = FunctionDeclaration

函数表达式 = FunctionExpression

箭头函数 = ArrowFunctionExpression

类方法 = ClassMethod

到这里我们确定了需要访问的AST节点类型,在代码中加上节点类型:

function autoTracker({types: t, template}) {
    return {
        visitor: { 
            'FunctionDeclaration|ArrowFunctionExpression|FunctionExpression|ClassMethod'(path, state) {
            }
        }
    };
}

module.exports = autoTracker;

多个类型我们使用|来分隔。

可以看到方法中有两个参数:

  1. path:path对象中维护了相邻节点的关系,可以通过path属性拿到父级和兄弟节点,path属性中还提供了增删改AST的方法。
  2. state:多个AST节点间需要进行数据传递,则通过state。

根据函数块级注释判断是否需要自动埋点

现在我们来实现第一步:根据函数块级注释判断是否需要自动埋点。

那么如何拿到函数的注释呢?

我们通过astexplorer来查看,注释的AST节点类型是什么:

1636096133345.jpg

可以看到,块级注释的AST节点类型为:leadingComments

那么我们就可以通过path去获取注释:

const coment = path.get("leadingComments")[0];

获取的coment:
*
  * autoTracker
  * @param {string} id - needTracker订单id
  * @param {string} name - 用户名

但是我们发现,不同类型的函数他们的AST结构不同:

// 函数声明
FunctionDeclaration: {
    type: "FunctionDeclaration",
    ...
    leadingComments: [],
}

// 函数表达式
VariableDeclarator: {
    type: "VariableDeclarator",
    declarations: [
        {type: "VariableDeclarator", init: FunctionExpression}
    ]
    ...
    leadingComments: [],
}

// 箭头函数
VariableDeclarator: {
    type: "VariableDeclarator",
    declarations: [
        {type: "VariableDeclarator", init: ArrowFunctionExpression}
    ]
    ...
    leadingComments: [],
}

// 类方法
ClassMethod: {
    type: "ClassMethod",
    ...
    leadingComments: [],
}

可以看到只有函数表达式和类方法的leadingComments属性在函数的AST节点下,可以使用path直接获取,但是函数表达式和箭头函数的leadingComments属性在它们的父级节点,所以我们在直接使用path获取不到leadingComments属性时,就要往当前节点的父节点查找:

'FunctionDeclaration|ArrowFunctionExpression|FunctionExpression|ClassMethod'(path, state) {
        const coment = path.get("leadingComments")[0] || {};
        const comentNode = coment.node;

        if(!comentNode) {
          // 当使用path获取不到leadingComments属性时,向它的父级查找
          path.findParent((parentPath) => {
            const coment = parentPath.node.leadingComments;
            if(!coment) {
                // 当父级节点找不到时,返回false继续向上找
            	return false;
            }else {
              // 当找到leadingComments属性时,返回true结束查找
              const comentNode = coment[0] || {};
              ...
              return true;
            }
          });
        }else {
            ...
        }
      },

这是我们已经能获取到函数的注释,现在需要将注释中的参数获取出来:

const AUTOTRACKER = 'autoTracker';

function setAutoTracker(path, state, template, comentNode) {
  // 只有块级类型的注释才去校验是否需要自动埋点
  if (comentNode.type === "CommentBlock") {
    // 提取注释
    const comentStr = comentNode.value.replace(/\s+/g, ""); // 去除空格
    const comentStrAry = comentStr.split('*').filter((item) => item); // 提取内容并去除空值
    const name = comentStrAry[0]; // 获取注释标题

    // 判断注释标题为AUTOTRACKER-自动埋点标识
    if(name === AUTOTRACKER) {
      
    }
  }
}

我们新建一个方法,在这个方法里面首先判断,注释的类型需要时块级注释才会去校验是否需要自动埋点。

我们获取到的注释是有格式的:

*
  * autoTracker
  * @param {string} id - needTracker订单id
  * @param {string} name - 用户名

所以我们首先需要将所有的空格去掉,然后根据*号分隔,并且使用filter方法取出空值。

最后提取的参数为:

["autoTracker", "@param{string}id-needTracker订单id", "@param{string}name-用户名"]

然后通过第一个参数的值判断是否与AUTOTRACKER相等,相等则判断为自动埋点函数。

然后我们需要再把上一次从注释中获取到的数据提取出埋点的参数:

// 获取注释中的参数
function parseComment(commentParamAry) {
   // 获取注释中的参数
   const paramAry = commentParamAry.map((param) => {
     const type = param.replace(/.*{(.*)}.*/, "$1"); // 获取参数类型
     const fieldStr = param.split('}')[1]; // 获取字段信息
     let name = fieldStr;
     let description = '';

     // 判断参数是否有描述
     if(fieldStr.indexOf("-") >= 0) {
       const fielAry = fieldStr.split('-');
       name = fielAry[0];
       description = fielAry[1];
     }

     return {
       type,
       name,
       description,
     };
   });

   return paramAry;
}

最后提取的参数为:

[{type: 'string', name: 'id', description: 'needTracker订单id'}, ...]

判断函数中是否声明了注释中的参数

接下来则需要判断我们函数中是否声明了注释中的参数,它们的位置以及名称都需要一一对应,如果没有则需要报错提示:

const generator = require('@babel/generator');
const {codeFrameColumns} = require('@babel/code-frame');

function verifyFuncParams(path, paramAry) {
  const params = path.get("params"); // 获取ast中函数节点的参数集合
  let errorResult = '';

  const isError = paramAry.some((param, index) => {
    const paramNode = params[index] || params[index - 1];
    if(param.name !== paramNode.node.name) {
      const commentLoc = path.get('leadingComments')[0].node.loc;
      const commentEndLine = commentLoc.end.line - commentLoc.start.line + 1; // 计算出注释最后一行的行数
      const codeStartLine = commentEndLine + 1; // 根据注释最后一行的行数计算出函数第一行的行数
      const {code} = generator.default(path.node); // 根据当前函数的ast生成代码
      const loc = paramNode.node.loc;
      const location = {
        start: { line: codeStartLine, column: loc.start.column },
        end: { line: codeStartLine, column: loc.end.column },
      };
      // 生成错误提示
      errorResult = codeFrameColumns(
        code, 
        location,
        {
          highlightCode: true, 
          message: `变量:${param.name} 未在函数中声明`
        },
      );
      return true;
    }
    return false;
  });

  if(isError && errorResult) {
    throw new Error(errorResult);
  }

  return isError;
}

这个函数中,通过获取当前函数AST节点的params参数与注释中的参数进行名称的对比,如果不一致则通过generator生成当前函数的代码,然后通过@babel/code-frame生成代码错误信息,最后使用throw将错误信息抛出,提示用户。

引入埋点函数

如果注释中的参数函数中都声明了,并且都正确,那么我们则需要向函数中插入埋点方法,在插入前,我们还需要引用埋点方法:

const importModule = require('@babel/helper-module-imports');

// 引用埋点函数
function trackerImportDeclaration(path, state) {
  const pathName = state.opts.pathName; // 获取埋点函数的文件路径
  const trackerImportName  = importModule.addDefault(path, pathName, {
    nameHint: path.scope.generateUid('tracker')
  }).name; 

  return trackerImportName;
}

我们首先通过state.opts.pathName拿到埋点方法所在文件的路径(pathName是我们在配置babel插件时需要传入的参数),然后通过@babel/helper-module-imports包将埋点方法引入进来,调用引入方法后会返回一个name,也就是我们平常引入外部文件时import的名称:import 名称 from 'xxx',该名称也就是我们后面需要插入埋点函数时所用到的函数名。

函数插装:将埋点函数插入

最后一步,我们需要将埋点方法插入到当前函数的首位:

 // 函数插装:将埋点函数插入
 const callParamsStr = paramAry.map((param => param.name)).join(', ');
 path.get("body").node.body.unshift(template(`${trackerImportName}({${callParamsStr}})`)());

首先我们将注释中的参数转为字符串:

'id, name'

然后调用引入的埋点方法,也就是上面返回的trackerImportName

`${trackerImportName}({${callParamsStr}})`

转为代码后:

xxTracker(id, name)

接着使用template生成AST节点:

template(`${trackerImportName}({${callParamsStr}})`)()

最后插入到函数首位:

path.get("body").node.body.unshift(template(`${trackerImportName}({${callParamsStr}})`)());

到这里自动埋点的插件就完成了。

最后展示一下效果:

QQ20211105-160314.gif

点击这里查看源码

关于节点类型以及所在位置,大家可以根据astexplorer去点击查看。