likes
comments
collection
share

babel实现自动化埋点的思路。

作者站长头像
站长
· 阅读数 41

埋点是一个常见的需求,就是在函数里面上报一些信息。像一些性能的埋点,每个函数都要处理,很繁琐。埋点只是在函数里面插入了一段代码,这段代码不影响其他逻辑,这种函数插入不影响逻辑的代码的手段叫做函数插桩。

那我们能对埋点做自动化嘛?答案是可以的,这里我们可以探讨如何使用babel来进行自动化埋点。

前置知识

  1. 首先我们要先了解babel做的事情,babel会将源代码解析成一颗抽象语法树,树中有很多个节点,通常我们称这个树为AST。
  2. 在AST中,对树中的每个节点做出修改,或者新增等转换,比如新增一个表达式节点,以此修改AST。
  3. 那么在上一步的修改有什么用呢?最后一步叫生成,这一步将AST输出为代码。

前置小结

babel将我们写的代码,解析为AST,然后我们可以在其中转换这棵抽象语法树,最后导致生成的代码也会不同。这篇文章就是讲如何转换,也就是修改AST的部分。

源代码以及生成的最终代码如下

源代码

import aa from 'aa';
import * as bb from 'bb';
import {cc} from 'cc';
import 'dd';

function a () {
    console.log('aaa');
}

class B {
    bb() {
        return 'bbb';
    }
}

const c = () => 'ccc';

const d = function () {
    console.log('ddd');
}

我们要在源代码之中插入一个函数执行的语句,也就是插入的桩,叫_tracker2()

最终代码

import _tracker2 from "tracker";
import aa from 'aa';
import * as bb from 'bb';
import { cc } from 'cc';
import 'dd';

function a() {
  _tracker2();

  console.log('aaa');
}

class B {
  bb() {
    _tracker2();

    return 'bbb';
  }

}

const c = () => {
  _tracker2();

  return 'ccc';
};

const d = function () {
  _tracker2();

  console.log('ddd');
};

实现思路

  • 使用babel引入 tracker 模块。如果已经引入过就不引入,没有的话就引入,并且生成个唯一 id 作为标识符
  • 对所有函数在函数体开始插入 _tracker2 的代码

如何引入模块并生成对应调用节点

可以通过引入helper-module-imports这个包里的importModule来进行引入,以下这段代码会生成如下代码

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

// 省略一些代码
importModule.addDefault(path, 'tracker',{
// 当有nameHint的时候,如果作用域内有tracker,那么就会产生名为_tracker2
// 如果作用域内也有这个变量,那么会产生_tracker3
    nameHint: path.scope.generateUid('tracker') 
})

生成结果

    import _tracker2 from 'tracker'

接着要判断是否被引入过,在 Program 根结点里通过 path.traverse 来遍历 ImportDeclaration,如果引入了 tracker 模块,就记录 id 到 state,并用 path.stop 来终止后续遍历;没有就引入 tracker 模块,用 generateUid 生成唯一 id,然后放到 state。

当然 default import 和 namespace import 取 id 的方式不一样,需要分别处理下。

我们把 tracker 模块名作为参数传入,通过 options.trackerPath 来取。 在最后的让babel使用上述代码,提到了指定插件选项的时候,指定了trackerPath为tracker。

Program: {
    enter (path, state) {
        path.traverse({
            ImportDeclaration (curPath) {
                const requirePath = curPath.get('source').node.value;
// 假设遍历到import语句:import _tracker2 from 'tracker',那么此时requirePath就为tracker
// 另一方面,我们已经指定了options.trackerPath为tracker
                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();// tracker 模块的 id
                    }
                    path.stop();// 找到了就终止遍历
                }
            }
        });
        if (!state.trackerImportId) {
            state.trackerImportId  = importModule.addDefault(path, 'tracker',{
                nameHint: path.scope.generateUid('tracker')
            }).name; // tracker 模块的 id
            state.trackerAST = api.template.statement(`${state.trackerImportId}()`)();// 埋点代码的 AST
        }
    }
}

这段代码的最终结果就是引入模块的同时,确保AST中会有能生成_tracker2()代码的节点,最后的state.trackerAST = api.template.statement(${state.trackerImportId}())();中的statement方法就是生成AST节点的代码。

函数插桩

这里要做的就是在函数体中插入_tracker2()节点,期望之后生成阶段能产生对应的代码。

函数插桩要找到对应的函数,这里要处理的有:ClassMethod、ArrowFunctionExpression、FunctionExpression、FunctionDeclaration 这些节点。

当然有的函数没有函数体,这种要包装一下,然后修改下 return 值。如果有函数体,就直接在开始插入就行了。

// 这段代码就是当babel处理到这些类型的节点的时候,执行这段代码。
'ClassMethod|ArrowFunctionExpression|FunctionExpression|FunctionDeclaration'(path, state) {
// 获取这些类型的节点中的body属性。
    const bodyPath = path.get('body');
    // 如果body属性包含了函数体,那么就直接插入埋点代码
    if (bodyPath.isBlockStatement()) { 
        bodyPath.node.body.unshift(state.trackerAST);
    } else { // 没有函数体要包裹一下,处理下返回值,比如源代码中的箭头函数。
        const ast = api.template.statement(`{${state.trackerImportId}();return PREV_BODY;}`)({PREV_BODY: bodyPath.node});
        // 替换ast,从而修改body节点里面的内容
        bodyPath.replaceWith(ast);
    }
}

tips: ClassMethod、ArrowFunctionExpression、FunctionExpression、FunctionDeclaration这几种类型的节点中的body属性,包含的也是节点。这些节点的类型根据函数体的内容不同而不同。

使用astexplorer将源代码复制进去,然后查找这几种类型的节点,并查看他们的body属性的内容。将会比较直观地感受上面这段代码的作用。

key中的name属性说明这个方法的名字,被红框选中的body属性则说明bb这个方法的函数体,这其中的body包含的ReturnStatement直接指明了这个函数体包含了返回值语句 babel实现自动化埋点的思路。

实现插桩插件的所有代码

这个插桩的所有代码如下,如果你看到这里还是不明白,建议是再看看上面的内容。

const { declare } = require('@babel/helper-plugin-utils');
const importModule = require('@babel/helper-module-imports');

const autoTrackPlugin = declare((api, options, dirname) => {
    api.assertVersion(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;
                        state.trackerAST = api.template.statement(`${state.trackerImportId}()`)();
                    }
                }
            },
            'ClassMethod|ArrowFunctionExpression|FunctionExpression|FunctionDeclaration'(path, state) {
                const bodyPath = path.get('body');
                if (bodyPath.isBlockStatement()) {
                    bodyPath.node.body.unshift(state.trackerAST);
                } else {
                    const ast = api.template.statement(`{${state.trackerImportId}();return PREV_BODY;}`)({PREV_BODY: bodyPath.node});
                    bodyPath.replaceWith(ast);
                }
            }
        }
    }
});
module.exports = autoTrackPlugin;

让babel使用上述代码

这里就要使用前置知识提到的解析、转换的概念。以及生成模块过程中所涉及到的trackerPath,也是在这里传入的

const { transformFromAstSync } = require('@babel/core');
const  parser = require('@babel/parser');
const autoTrackPlugin = require('./plugin/auto-track-plugin');
const fs = require('fs');
const path = require('path');

// 加载源代码作为字符串
const sourceCode = fs.readFileSync(path.join(__dirname, './sourceCode.js'), {
    encoding: 'utf-8'
});

// 解析为ast,parser就是解析
const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous'
});

// 使用transformFromAstSync,传入ast,源代码,以及我们刚刚编写的插件。然后给其中的选项传入一个属性值
// trackerPath
const { code } = transformFromAstSync(ast, sourceCode, {
    plugins: [[autoTrackPlugin, {
        trackerPath: 'tracker'
    }]]
});

// 在控制台打印源代码
console.log(code)

总结

这篇文章其实是根据神说要有光的babel小册其中的自动插桩实战来写的。在看的过程中我调试了数次。觉得是有必要写下来的。同时也参考了很多文档,在这里列给大家参考。

转载自:https://juejin.cn/post/7251104465729994807
评论
请登录