「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」
关于Babel
Babel
是一个Javascript的编译器,主要用于将es next
(ECMScript2015,ECMScript2016,ECMScript2017等)的语法转换为向后兼容的JavaScript语法,使代码能够运行在当前的和旧版本的浏览器或其它环境中。
Babel编译流程
Babel编译流程分三步:
- parse: 将源码转换成AST语法树
- transform: 调用各种transform插件对AST语法树进行增删改
- generate: 根据修改后的AST语法树生成代码,并生成sourcemap
parse
parse的作用主要是将字符串形式的源码转换成AST语法树,这个过程有分为词法分析和语法分析。
词法分析主要是将字符串形式的源码解析为tokens,可以理解为将这样一段代码:
let a = 1
根据单词规则来拆分成最小形式的字符串(token):
let
,a
, =
, 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。
埋点插件
实现插件前,我们先来确定插件的功能:
- 根据函数块级注释判断是否需要自动埋点
- 从注释中提取出埋点所需要的参数
- 引入埋点函数文件
- 函数插装:将埋点方法插入需要埋点的函数,并传入参数
首先,先写一个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;
多个类型我们使用|
来分隔。
可以看到方法中有两个参数:
- path:path对象中维护了相邻节点的关系,可以通过path属性拿到父级和兄弟节点,path属性中还提供了增删改AST的方法。
- state:多个AST节点间需要进行数据传递,则通过state。
根据函数块级注释判断是否需要自动埋点
现在我们来实现第一步:根据函数块级注释判断是否需要自动埋点。
那么如何拿到函数的注释呢?
我们通过astexplorer来查看,注释的AST节点类型是什么:
可以看到,块级注释的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}})`)());
到这里自动埋点的插件就完成了。
最后展示一下效果:
关于节点类型以及所在位置,大家可以根据astexplorer去点击查看。