目录
在create-reate-app中使用我们手写的babel插件
前言
需求:做性能埋点,每个函数都要处理。
想法:自动埋点。
解释:埋点只是在函数里插入了一段代码,这段代码不影响其他逻辑。
概念:这种函数中 插入不影响逻辑代码的手段 叫做函数插桩。
工具:babel。
// 想到的函数类型
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');
}
// 不同类型函数达到的埋点效果
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');
};
实现思路:
·引入 tracker 模块。如果已经引入就不再引入,没有的话就引入,并且生成唯一 ID 作为标识符
·对所有函数在函数体内插入 tracker 代码
AST
// 留意到 AST 的每一层都拥有相同的结构
{
type: "FunctionDeclaration",
id: {...},
params: [...],
body: {...}
}
{
type: "Identifier",
name: ...
}
{
type: "BinaryExpression",
operator: ...,
left: {...},
right: {...}
}
// Babel 还为每个节点额外生成了一些属性,用于描述该节点在原始代码中的位置
{
type: ...,
start: 0,
end: 38,
loc: {
start: {
line: 1,
column: 0
},
end: {
line: 3,
column: 1
}
},
...
}
// 每一个节点都会有 start、end、loc 这几个属性。
Babel 的三个主要处理步骤分别是: 解析(parse),转换(transform),生成(generate)。
转换步骤接收 AST 并对其进行遍历,在此过程中对节点进行添加、更新及移除等操作。这是 Babel 或是其他编译器中最复杂的过程。同时也是插件将要介入工作的部分,这将是本手册的主要内容。
遍历
Visitors
当我们谈及“进入”一个节点,实际上是说我们在访问它们,之所以使用这样的术语是因为有一个访问者模式(visitor)的概念。
访问者是一个用于 AST 遍历的跨语言的模式。 简单的说它们就是一个对象,定义了用于在一个树状结构中获取具体节点的方法。 这么说有些抽象所以让我们来看一个例子。
const MyVisitor = {
Identifier() {
console.log("Called!");
}
};
// 你也可以先创建一个访问者对象,并在稍后给它添加方法。
let visitor = {};
visitor.MemberExpression = function() {};
visitor.FunctionDeclaration = function() {}
// 使用访问者
path.traverse(MyVisitor);
Paths(路径)
AST 通常会有许多节点,那么节点直接如何相互关联呢?可以用 Paths(路径)来简化这件事情。Path 是表示两个节点之间连接的对象。
// 例如,如果有下面这样一个节点及其子节点
{
type: "FunctionDeclaration",
id: {
type: "Identifier",
name: "square"
},
...
}
//将子节点 Identifier 表示为一个路径(Path)的话,看起来是这样的:
{
"parent": {
"type": "FunctionDeclaration",
"id": {...},
....
},
"node": {
"type": "Identifier",
"name": "square"
}
}
同时,还包含关于该路径的其他元数据。当然路径对象还包含添加、更新、移动和删除节点有关的其他很多方法。
{
"parent": {...},
"node": {...},
"hub": {...},
"contexts": [],
"data": {},
"shouldSkip": false,
"shouldStop": false,
"removed": false,
"state": null,
"opts": null,
"skipKeys": null,
"parentPath": null,
"context": null,
"container": null,
"listKey": null,
"inList": false,
"parentKey": null,
"key": null,
"scope": null,
"type": null,
"typeAnnotation": null
}
在某种意义上,路径是一个节点在树中的位置以及关于该节点各种信息的响应式 reactive 表示。当你调用一个修改树的方法后,路径信息也会被更新。Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。
Paths in Visitors(存在于访问者中的路径)
当你有一个 Identifier() 成员方法的访问者时,你实际上是在访问路径而非节点。通过这种方式,你操作的就是节点的响应式表示(即路径)而非节点本身。
const MyVisitor = {
Identifier(path) {
console.log("Visiting: " + path.node.name);
}
};
a + b + c;
path.traverse(MyVisitor);
Visiting: a
Visiting: b
Visiting: c
State(状态)
状态是抽象语法树 AST 转换的敌人,状态管理会不断牵扯你的精力,而且几乎所有你对状态的假设,总是会有一些未考虑到的语法最终证明你的假设是错误的。
Scopes(作用域)
JavaScript 支持词法作用域,在树状嵌套结构中代码块创建出新的作用域。
当编写一个转换时,必须小心作用域。我们得确保在改变代码的各个部分时不会破坏已经存在的代码。
// 作用域可以被表示为如下形式:
{
path: path,
block: path.node,
parentBlock: path.parent,
parent: parentScope,
bindings: [...]
}
当你创建一个新的作用域时,需要给出它的路径和父作用域,之后在遍历过程中它会在该作用域内收集所有的引用(“绑定”)。
一旦引用收集完毕,你就可以在作用域(Scopes)上使用各种方法,稍后我们会了解这些方法。
Bindings(绑定)
所有引用属于特定的作用域,引用和作用域的这种关系被称作:绑定(binding)。
// 单个绑定看起来像这样
Text for Translation
{
identifier: node,
scope: scope,
path: path,
kind: 'var',
referenced: true,
references: 3,
referencePaths: [path, path, path],
constant: false,
constantViolations: [path]
}
有了这些信息你就可以查找一个绑定的所有引用,并且知道这是什么类型的绑定(参数,定义等等),查找它所属的作用域,或者拷贝它的标识符。 你甚至可以知道它是不是常量,如果不是,那么是哪个路径修改了它。
在很多情况下,知道一个绑定是否是常量非常有用,最有用的一种情形就是代码压缩时。
API
babylon
Babylon 是 Babel 的解析器。最初是从 Acorn 项目 fork 出来的。Acorn 非常快,易于使用,并且针对非标准特性(以及那些未来的标准特性)设计了一个基于插件的架构。
// 使用方式
import * as babylon from "babylon";
const code = `function square(n) {
return n * n;
}`;
babylon.parse(code);
// Node {
// type: "File",
// start: 0,
// end: 38,
// loc: SourceLocation {...},
// program: Node {...},
// comments: [],
// tokens: [...]
// }
babel-traverse
Babel Traverse(遍历)模块维护了整棵树的状态,并且负责替换、移除和添加节点。
// 使用方式:可以和 Babylon 一起使用来遍历和更新节点
import * as babylon from "babylon";
import traverse from "babel-traverse";
const code = `function square(n) {
return n * n;
}`;
const ast = babylon.parse(code);
traverse(ast, {
enter(path) {
if (
path.node.type === "Identifier" &&
path.node.name === "n"
) {
path.node.name = "x";
}
}
});
babel-types
Babel Types 模块是一个用于 AST 节点的 Lodash 式工具库(Lodash 是一个 JavaScript 函数工具库,提供了基于函数式编程风格的众多工具函数),它包含了构造、验证以及变换 AST 节点的方法。该工具库包含考虑周到的工具方法,对编写处理 AST 逻辑非常有用。
Definitions(定义)
Babel Types 模块拥有每一个单一类型节点的定义,包括节点包含哪些属性,什么是合法值,如何构建节点、遍历节点,以及节点的别名等信息。
// 单一节点类型的定义形式如下
defineType("BinaryExpression", {
builder: ["operator", "left", "right"],
fields: {
operator: {
validate: assertValueType("string")
},
left: {
validate: assertNodeType("Expression")
},
right: {
validate: assertNodeType("Expression")
}
},
visitor: ["left", "right"],
aliases: ["Binary", "Expression"]
});
Builders(构建器)
你会注意到上面的 BinaryExpression 定义有一个 builder 字段。这是由于每一个节点类型都有构造器方法 builder,按类似下面的方式使用:
t.binaryExpression("*", t.identifier("a"), t.identifier("b"));
可以创建如下所示的 AST:
{
type: "BinaryExpression",
operator: "*",
left: {
type: "Identifier",
name: "a"
},
right: {
type: "Identifier",
name: "b"
}
}
当打印出来之后是这样的:
a * b
Validators(验证器)
BinaryExpression 的定义还包含了节点的字段 fields 信息,以及如何验证这些字段。
// 可以创建两种验证方法
// 第一种 isX
t.isBinaryExpression(maybeBinaryExpressionNode)
// 这个测试也可以传入第二个参数来确保节点包含特定的属性和值
t.isBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// 第二种 断言式。会抛出异常而不是返回 true 或 false
t.assertBinaryExpression(maybeBinaryExpressionNode);
t.assertBinaryExpression(maybeBinaryExpressionNode, { operator: "*" });
// Error: Expected type "BinaryExpression" with option { "operator": "*" }
Converters(变换器)
babel-generator
Babel Generator 模块是 Babel 的代码生成器,它读取 AST 并将其转换为代码和源码映射(sourcemaps)。
// 使用方式
import * as babylon from "babylon";
import generate from "babel-generator";
const code = `function square(n) {
return n * n;
}`;
const ast = babylon.parse(code);
generate(ast, {}, code);
// {
// code: "...",
// map: "..."
// }
babel-template
babel-template 是另一个虽然很小但却非常有用的模块。它能让你编写字符串形式且带有占位符的代码来代替手动编码,尤其是生成的大规模 AST 的时候。在计算机科学中,这种能力被称作准引用(quasiquotes)。
// 使用方式
import template from "babel-template";
import generate from "babel-generator";
import * as t from "babel-types";
const buildRequire = template(`
var IMPORT_NAME = require(SOURCE);
`);
const ast = buildRequire({
IMPORT_NAME: t.identifier("myModule"),
SOURCE: t.stringLiteral("my-module")
});
console.log(generate(ast).code);
var myModule = require("my-module");
编写第一个 Babel 插件
// 先从一个接收了当前 babel 对象作为参数的 function 开始。
export default function( babel ) {
// plugin contents
}
// 由于你将会经常这样使用,所以直接取出 babel.types 会更方便;
export default function( {types: t} ) {
// plugin contents
}
// 接着返回一个对象,其 visitor 属性是这个插件的主要访问者
export default function( {types: t} ) {
return {
visitor: {
// visitor contents
}
}
}
// Visitor 中的每个函数接收2个参数:path 和 state
export default function( {types: t} ) {
return {
visitor: {
Identifier(path, state) {},
ASTNodeTypeHere(path, state) {}
}
}
}
// 让我们快速编写一个可用的插件来展示一下它是如何工作的。下面是我们的源码:
foo === bar;
// 其 AST 形式如下:
{
type: "BinaryExpression",
operator: "===",
left: {
type: "Identifier",
name: "foo"
},
right: {
type: "Identifier",
name: "bar"
}
}
// 我们从添加 BinaryExpression 访问者方法开始:
export default function({ types: t }) {
return {
visitor: {
BinaryExpression(path) {
// ...
}
}
}
}
// 然后我们更确切一些,只关注哪些使用了 === 的 BinaryExpression
visitor: {
BinaryExpression(path) {
if (path.node.operator !== "===") {
return;
}
// ...
}
}
// 现在我们用新的标识符来替换 left 属性:
BinaryExpression(path) {
if (path.node.operator !== "===") {
return;
}
path.node.left = t.identifier("sebmck");
// ...
}
// 于是如果我们运行这个插件我们会得到:
sebmck === bar;
// 现在只需要替换 right 属性了。
BinaryExpression(path) {
if (path.node.operator !== "===") {
return;
}
path.node.left = t.identifier("sebmck");
path.node.right = t.identifier("dork");
}
// 这就是我们的最终结果了:
sebmck === dork;
// 完美!我们的第一个 Babel 插件。
转换操作
访问
获取子节点的 Path
为了得到一个 AST 节点的属性值,我们一般先访问到该节点,然后利用 path.node.property 方法即可。
// the BinaryExpression AST node has properties: `left`, `right`, `operator`
BinaryExpression(path) {
path.node.left;
path.node.right;
path.node.operator;
}
// 如果你想访问到该属性内部的 path,使用 path 对象的 get 方法,传递该属性的字符串形式作为参数。
BinaryExpression(path) {
path.get('left');
}
Program(path) {
path.get('body.0');
}
检查节点的类型
// 如果你想检查节点的类型,最好的方式是:
BinaryExpression(path) {
if (t.isIdentifier(path.node.left)) {
// ...
}
}
// 你同样可以对节点的属性们做浅层检查
BinaryExpression(path) {
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}
检查路径(Path)类型
// 一个路径具有相同的方法检查节点的类型
BinaryExpression(path) {
if (path.get('left').isIdentifier({ name: "n" })) {
// ...
}
}
// 就相当于:
BinaryExpression(path) {
if (t.isIdentifier(path.node.left, { name: "n" })) {
// ...
}
}
检查标识符(Identifier)是否被引用
Identifier(path) {
if (path.isReferencedIdentifier()) {
// ...
}
}
// 或者:
Identifier(path) {
if (t.isReferenced(path.node, path.parent)) {
// ...
}
}
找到特定的父路径
有时你需要从一个路径向上遍历语法树,直到满足相应的条件。
对于每一个父路径调用callback
并将其NodePath
当作参数,当callback
返回真值时,则将其NodePath
返回。
path.findParent((path) => path.isObjectExpression());
如果也需要遍历当前节点:
path.find((path) => path.isObjectExpression());
查找最接近的父函数或程序:
path.getFunctionParent();
向上遍历语法树,直到找到在列表中的父节点路径
path.getStatementParent();
获取同级路径
如果一个路径是在一个 Function
/Program
中的列表里面,它就有同级节点。
- 使用
path.inList
来判断路径是否有同级节点, - 使用
path.getSibling(index)
来获得同级路径, - 使用
path.key
获取路径所在容器的索引, - 使用
path.container
获取路径的容器(包含所有同级节点的数组) - 使用
path.listKey
获取容器的key
var a = 1;
// pathA, path.key = 0 var b = 2;
// pathB, path.key = 1 var c = 3;
// pathC, path.key = 2
```js
export default function({ types: t }) {
return {
visitor: {
VariableDeclaration(path) {
// if the current path is pathA
path.inList // true
path.listKey // "body"
path.key // 0
path.getSibling(0) // pathA
path.getSibling(path.key + 1) // pathB
path.container // [pathA, pathB, pathC]
}
}
};
}
停止遍历
如果你的插件需要在某种情况下不运行,最简单的做法是尽早写回。
BinaryExpression(path) {
if (path.node.operator !== '**') return;
}
如果您在顶级路径中进行子遍历,则可以使用2个提供的API方法:
path.skip()
skips traversing the children of the current path.
path.stop()
stops traversal entirely.
outerPath.traverse({
Function(innerPath) {
innerPath.skip(); // if checking the children is irrelevant
},
ReferencedIdentifier(innerPath, state) {
state.iife = true;
innerPath.stop(); // if you want to save some state and then stop traversal, or deopt
}
});
处理
替换一个节点
BinaryExpression(path) {
path.replaceWith(
t.binaryExpression('**', path.node.left, t.numberLiteral(2))
)
}
function square(n) {
return n * n; -
return n ** 2; +
}
用多节点替换单节点
ReturnStatement(path) {
path.replaceWithMultiple([
t.expressionStatement(t.stringLiteral("Is this the real life?")),
t.expressionStatement(t.stringLiteral("Is this just fantasy?")),
t.expressionStatement(t.stringLiteral("(Enjoy singing the rest of the song in your head)")),
])
}
function square(n) {
- return n * n
+ "Is this the real life?";
+ "Is this just fantasy?";
+ "(Enjoy singing the rest of the song in your head)";
}
用字符串源码替换节点
FunctionDeclaration(path) {
path.replaceWithSourceString(
function add(a, b) {
return a + b;
}
);
}
```diff
- function square(n) {
- return n * n;
+ function add(a, b) {
+ return a + b;
}
插入兄弟节点
FunctionDeclaration(path) {
path.insertBefore(
t.expressionStatement(
t.stringLiteral("Because I'm easy come, easy go.")
)
)
path.insertAfter(
t.expressionStatement(
t.stringLiteral("A little high, little low.")
)
)
}
```diff
+ "Because I'm easy come, easy go.";
function square(n) {
return n * n;
}
+ "A little high, little low.";
插入到容器(container)中
如果您想要在 AST 节点属性中插入一个像 body 那样的数组。它与 <code> insertBefore / insertAfter 类似,但您必须指定 listKey(通常是 正文)
ClassMethod(path) {
path.get('body').unshiftContainer('body', t.expressionStatement(t.stringLiteral('before')));
path.get('body').pushContainer('body', t.expressionStatement(t.stringLiteral('after')));
}
```diff
class A {
constructor() {
+ "Before"
var a = 'middle';
+ "after"
}
}
删除一个节点
FunctionDeclaration(path) {
path.remove();
}
- function square(n) {
- return n * n;
- }
替换父节点
// 只需要使用 parentPath: path.parentPath 调用 replace 即可
BinaryExpression(path) {
path.parentPath.replaceWith(
t.expressionStatement( t.stringLiteral("Anywhere the wind blows, doesn't really matter to me, to me."))
);
}
function square(n) {
- return n * n;
+ "Anyway the wind blows, doesn't really matter to me, to me.";
}
删除父节点
BinaryExpression(path) {
path.parentPath.remove();
}
function square(n) {
- return n * n;
}
Scope(作用域)
检查本地变量是否被绑定
FunctionDeclaration(path) {
if(path.scope.hasBinding("n")) {
// ...
}
}
// 这将遍历范围树并检查特定的绑定。
// 您也可以检查一个作用域是否有 自己的绑定
FunctionDeclaration(path) {
if (path.scope.hasOwnBinding("n")) {
// ...
}
}
创建一个 UID
// 这将生成一个标识符,不会与任何本地定义的变量相冲突
FunctionDeclaration(path) {
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid" }
path.scope.generateUidIdentifier("uid");
// Node { type: "Identifier", name: "_uid2" }
}
提升变量声明至父级作用域
// 有时你可能想要推送一个 VariableDeclaration,这样你就可以分配给它。
FunctionDeclaration(path) {
const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
path.remove();
path.scope.parent.push({ id, init: path.node });
}
- function square(n) {
+ var _square = function square(n) {
return n * n;
- }
+ };
重命名绑定及其引用
FunctionDeclaration(path) {
path.scope.rename("n", "x");
}
- function square(n) {
- return n * n;
+ function square(x) {
+ return x * x;
}
// 或者,您可以将绑定重命名为生成的唯一标识符
FunctionDeclaration(path) {
path.scope.rename("n");
}
- function square(n) {
- return n * n;
+ function square(_n) {
+ return _n * _n;
}
案例1 代码实现
模块引入
引入模块这种功能的公共函数会放在 helper 中,这里我们使用 @babel/helper-module-imports。
const importModule = require('@babel/helper-module-imports');
// 省略一些代码
importModule.addDefault(path, 'tracker',{
nameHint: path.scope.generateUid('tracker')
})
首先要判断是否被引入过:在 Program 根节点里通过 path.traverse 来遍历 ImportDeclaration,如果引入了 tracker 模块,就记录 id 到 state 中,并用 path.stop 来终止后续遍历;没有引入的话就引入 tracker 模块,用 generateUid 生成唯一 id,然后放到 state 中。
当然 default import 和 namespace import 取 id 的方式不一样,需要分别处理下。
Program: {
enter (path, state) {
path.traverse({
ImportDeclaration (curPath) {
const requirePath = curPath.get('source').node.value;
// 如果已经引入了
// 我们把 tracker 模块名作为参数传入,通过 options.trackerPath 来取。
if (requirePath === options.trackerPath) {
const specifierPath = curPath.get('specifiers.0');
if (specifierPath.isImportSpecifier()) {
state.trackerImportId = specifierPath.toString();
} else if(specifierPath.isImportNamespaceSpecifier()) {
// tracker 模块的 id
state.trackerImportId = specifierPath.get('local').toString();
}
// 找到了就终止遍历
path.stop();
}
}
});
if (!state.trackerImportId) {
// tracker 模块的 id
state.trackerImportId = importModule.addDefault(path, 'tracker',{
nameHint: path.scope.generateUid('tracker')
}).name;
// 在记录 tracker 模块 id 的时候,也生成调用 tracker 模块的 AST,使用 template.statement.
// 埋点代码的 AST
state.trackerAST = api.template.statement(`${state.trackerImportId}()`)();
}
}
}
函数插桩
函数插桩要找到对应的函数,这里要处理的有:ClassMethod、ArrowFunctionExpression、FunctionExpression、FunctionDeclaration 这些节点。
'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);
}
}
插件使用
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'
});
const ast = parser.parse(sourceCode, {
sourceType: 'unambiguous'
});
const { code } = transformFromAstSync(ast, sourceCode, {
plugins: [[autoTrackPlugin, {
trackerPath: 'tracker'
}]]
});
console.log(code);
小结
// 插件整体代码
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 来自动做。
实现思路分为“引入 tracker 模块”和“函数插桩”两部分。
1. 引入 tracker 模块需要判断 ImportDeclaration 是否包含了 tracker 模块,没有的话就用 @babel/helper-module-import 来引入。
2. 函数插桩就是在函数体开始位置插入一段代码,如果没有函数体,需要包装一层,并且处理下返回值。
案例2 代码实现
安装依赖
mrdir babel-tracker
cd ./babel-tracker
npm init -y
npm i -D @babel/core @babel/helper-plugin-utils
编写入口文件
const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker");
const pathFile = path.resolve(__dirname, "./sourceCode.js");
// transform ast and generate code
// 先将代码转成AST语法树,然后用插件对AST对象树做一系列的处理,最后将处理好的AST转回js代码。
const { code } = transformFileSync(pathFile, {
plugins: [
[tracker, {trackerPath: 'tracker'}]
],
});
console.log(code);
编写插件
一般在 bable 中处理 import,是在 Program 的 AST 节点中处理的,所以需要在插件中处理 Program 节点。
基本思路:判断文件中是否有 _tracker 的 import,如果没有,就添加一个导入。
添加了一个 Program 的处理函数。在逻辑中,遍历的了整个文件的 import 语句,并且一一比较了 import 的 source
,如果其中的 source.value
有 _tracker
,说明文件已经导入了_tracker。
一个 import 语句,如:
import a from 'a.js'
那么可以通过node.source.value
,获取这个 AST 节点中的a.js
在判断 _tracker
的导入路径的时候,代码中是从 options.trackerPath
中获取的,而 options 的配置是在插件引用的地方。 并没有 hard code。
如果没有发现 tracker 的导入,就需要手动添加了。代码中借用的是 addDefault 的依赖帮忙添加的。其中 { nameHint: "_tracker" }
用来设置 _tracker 作为埋点函数的变量名。
在代码中,做了一个对函数节点 body 属性值类型的判断,如果是 isBlockStatement,那就可以执行 unshift,如果不是,说明函数单纯返回了一个值,这时候就需要将函数体变成 blockStatement,并且函数的返回值依然是原来的值。形如 const test_5 = ()=>0
变成const test_5 = ()=>{ return 0; }
。这样就可以添加埋点函数了。
处理埋点函数变量名思路:
使用了 path.scope.generateUid("tracker")
来生成当前作用域内唯一的变量。
借助state,来传递生成的变量,或者是已经定义的变量。
在插入埋点函数的时候,就可以读取state中的变量了。
// 导入 `@babel/helper-module-imports` 包的 `addDefault` 函数
// 它可以向程序中添加默认导入
const { addDefault } = require("@babel/helper-module-imports");
// 导出一个 Babel 插件的函数。它接受两个参数:
// `api` 是一个 Babel 插件 API 对象,提供了一些可以在插件中使用的方法。
// `options` 是用户在 Babel 配置文件中给该插件指定的选项。
module.exports = (api, options) => {
// 返回一个插件对象
return {
// visitor 对象定义了我们要访问的 AST 节点类型以及对应的处理方法
visitor: {
// 对于四种类型的节点
"ArrowFunctionExpression|FunctionDeclaration|ClassMethod|FunctionExpression": {
// 当我们进入一个节点时
enter: (path, state) => {
// path 是当前节点的路径对象,它提供了一些操作当前节点的方法
const types = api.types;
// 获取函数的函数体的路径
const bodyPath = path.get("body");
const ast = state.trackerAst;
if (types.isBlockStatement(bodyPath.node)) {
// 将新生成的 ‘_tracker()’ 调用语句插入到函数的函数体的开头
bodyPath.node.body.unshift(ast);
} else {
// 使用 Babel 插件 API 的 template.statement 方法创建一个新的 AST 节点
// 这个节点表示 ‘_tracker()’ 这个语句。注意我们需要调用返回的函数 (()) 以生成 AST
const ast2 = api.template.statement(`{
${state.importTrackerId}();
return BODY;
}`)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast2);
}
},
},
// 对于 Program 类型的节点(整个程序):
Program: {
// 当我们进入一个节点时:
enter: (path, state) => {
// 从插件选项中获取 ‘_tracker’ 函数的导入路径
const trackerPath = options.trackerPath;
// 遍历当前节点(整个程序)的所有子节点
path.traverse({
// 对于 ImportDeclaration 类型的节点(导入声明)
ImportDeclaration(path) {
// 如果当前导入声明的来源与 ‘_tracker’ 函数的导入路径相同
if (path.node.source.value === trackerPath) {
const specifiers = path.get("specifiers.0");
state.importTrackerId = specifiers.get("local").toString();
// 停止遍历
path.stop();
}
},
});
if (!state.importTrackerId) {
// 使用 addDefault 函数向程序中添加 ‘_tracker’ 函数的默认导入
// options.trackerPath 是 _tracker 函数的导入路径
// { nameHint: …… } 是一个选项对象,用于指定导入的变量名
state.importTrackerId = addDefault(path, options.trackerPath, {
nameHint: path.scope.generateUid("tracker"),
}).name;
}
state.trackerAst = api.template.statement(`${state.importTrackerId}();`)();
},
},
},
}
}
小结
讲了如何在函数中添加埋点函数,以及如何处理埋点函数的import。在埋点的时候,需要注意以下几个问题:
函数形态的多样性
埋点函数的变量是否已经定义,如果已经定义,插入埋点的时候,就要使用已经定义的变量名;如果没有定义,插入import的时候,就要保证插全局变量名的唯一性
案例3 注释埋点
主要讲如何根据注释,通过 babel 插件自动地,给相应函数插入埋点代码,在实现埋点逻辑和业务逻辑分离的基础上,配置更加灵活。
效果展示
源代码:
//##箭头函数
//_tracker
const test1 = () => {};
const test1_2 = () => {};
转译之后:
import _tracker from "tracker";
//##箭头函数
//_tracker
const test1 = () => {
_tracker();
};
const test1_2 = () => {};
代码中有两个函数,其中一个 //_tracker
的注释,另一个没有。转译之后只给有注释的函数添加埋点函数。
要达到这个效果就需要读取函数上面的注释,如果注释中有 //_tracker
,我们就给函数添加埋点。这样做避免了僵硬的给每个函数都添加埋点的情况,让埋点更加灵活。
插件使用
const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker-comment.js");
const pathFile = path.resolve(__dirname, "./sourceCode.js");
//transform ast and generate code
const { code } = transformFileSync(pathFile, {
plugins: [[tracker, { trackerPath: "tracker", commentsTrack: "_tracker" }]],
});
console.log(code);
这里我们使用 transformFileSync
API 转译源代码,并将转译之后的代码打印出来。过程中,将手写的插件作为参数传入 plugins: [[tracker, { trackerPath: "tracker", commentsTrack: "_tracker"}]]
。除此之外,还有插件的参数:
trackerPath
表示埋点函数的路径,插件在插入埋点函数之前会检查是否已经引入了该函数,如果没有引入就需要额外引入。
commentsTrack 埋点
标识,如果函数前的注释有这个,就说明函数需要埋点。判断的标识是动态传入的,这样比较灵活。
源代码
import "./index.css";
//##箭头函数
//_tracker
const test1 = () => {};
const test1_2 = () => {};
//函数表达式
//_tracker
const test2 = function () {};
const test2_1 = function () {};
// 函数声明
//_tracker
function test3() {}
function test3_1() {}
插件编写
const { declare } = require("@babel/helper-plugin-utils");
const { addDefault } = require("@babel/helper-module-imports");
const { template } = require("@babel/core");
//get comments path from leadingComments
const hasTrackerComments = (leadingComments, comments) => {
if (!leadingComments) {
return false;
}
if (Array.isArray(leadingComments)) {
const res = leadingComments.filter((item) => {
return item.node.value.includes(comments);
});
return res[0] || null;
}
return null;
};
//insert path
const insertTracker = (path, param, state) => {
const bodyPath = path.get("body");
if (bodyPath.isBlockStatement()) {
const ast = template.statement(`${state.importTackerId}(${param});`)();
bodyPath.node.body.unshift(ast);
} else {
const ast = template.statement(`{
${state.importTackerId}(${param});
return BODY;
}`)({ BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};
//check if tacker func was imported
const checkImport = (programPath, trackPath) => {
let importTrackerId = "";
programPath.traverse({
ImportDeclaration(path) {
const sourceValue = path.get("source").node.value;
if (sourceValue === trackPath) {
const specifiers = path.get("specifiers.0");
importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});
if (!importTrackerId) {
importTrackerId = addDefault(programPath, trackPath, {
nameHint: programPath.scope.generateUid("tracker"),
}).name;
}
return importTrackerId;
};
module.exports = declare((api, options) => {
console.log("babel-plugin-tracker-comment");
return {
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|FunctionExpression": {
enter(path, state) {
let nodeComments = path;
if (path.isExpression()) {
nodeComments = path.parentPath.parentPath;
}
// 获取leadingComments
const leadingComments = nodeComments.get("leadingComments");
const paramCommentPath = hasTrackerComments(leadingComments,options.commentsTrack);
//查看作用域中是否有——trackerParam
// 如果有注释,就插入函数
if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;
insertTracker(path, state);
}
},
},
},
};
});
代码难点讲解:
AST 对象中,用 leadingComments
表示前面的注释,用 trailingComments
表示后面的注释。用 CommentBlock 表示块注释( /**/ ),用 CommentLine 表示行注释( // )。
拿到 import 语句
需要 program 节点。checkImport
函数的实现就是在当前文件中,找出埋点函数的引入。寻找的过程中,用到了引入插件时传入的参数 trackerPath
。还用到了 traverse
API,用来遍历 import 语句
。
如果找到了引入,就获取引入的变量。这个变量在之后埋点的时候需要。即如果引入的变量命名为 tracker2
,那么埋点的时候埋点函数就是 tracker2
了。
如果没有引入,就插入引入。
addDefault
就是引入 path 的函数,并且会返回插入引用的变量。
在生成埋点函数的时候,就用到了之前获取到的埋点函数的变量 importTackerId
。还有在实际插入的时候,要区分函数体是一个 Block
,还是直接返回的值--()=>''。
在获取注释的时候,代码中并不是直接获取到 path
的 leadingComments
,这是为什么?
比如这串代码:
// _tracker
const test1 = () => {};
我们在函数中遍历得到的 path 是 ()=>{}
ast 的 path,这个 path 的 leadingComments
其实是 null
,而想要获取 //_tracker
,我们真正需要拿到的 path,是注释下面的变量声明语句
。所以在代码中有判断是否为表达式,如果是,那就需要先获取 parentPath
,得到赋值表达式
的 path
,然后再获取 parentPath
,才能拿到变量声明语句。
案例4 埋点传参
那想要给插入的埋点函数传入参数应该怎么做呢?
传入参数可以有两个思路,
- 一个是将参数也放在注释里面,在babel插入代码的时候读取下注释里的内容就好了;
- 另一个是将参数以局部变量的形式放在当前作用域中,在babel插入代码时读取下当前作用域的变量就好;
下面我们来实现这两个思路,大家挑个自己喜欢的方法就好
参数放在注释中
源代码
import "./index.css";
//##箭头函数
//_tracker,_trackerParam={name:'gongfu', age:18}
const test1 = () => {};
//_tracker
const test1_2 = () => {};
插件使用
const { transformFileSync } = require("@babel/core");
const path = require("path");
const tracker = require("./babel-plugin-tracker-comment.js");
const pathFile = path.resolve(__dirname, "./sourceCode.js");
//transform ast and generate code
const { code } = transformFileSync(pathFile, {
plugins: [[tracker, { trackerPath: "tracker", commentsTrack: "_tracker",commentParam: "_trackerParam" }]],
});
console.log(code);
使用了 transformFileSync
API 转译源代码,并将转译之后的代码打印出来。过程中,将手写的插件作为参数传入 plugins: [[tracker, { trackerPath: "tracker", commentsTrack: "_tracker"}]]
。除此之外,还有插件的参数
trackerPath
表示埋点函数的路径,插件在插入埋点函数之前会检查是否已经引入了该函数,如果没有引入就需要额外引入。commentsTrack
标识埋点,如果函数前的注释有这个,就说明函数需要埋点。判断的标识是动态传入的,这样比较灵活commentParam
标识埋点函数的参数,如果注释中有这个字符串,那后面跟着的就是参数了。就像上面源代码所写的那样。这个标识不是固定的,是可以配置化的,所以放在插件参数的位置上传进去
编写插件
const { declare } = require("@babel/helper-plugin-utils");
const { addDefault } = require("@babel/helper-module-imports");
const { template } = require("@babel/core");
//judge if there are trackComments in leadingComments
const hasTrackerComments = (leadingComments, comments) => {
if (!leadingComments) {
return false;
}
if (Array.isArray(leadingComments)) {
const res = leadingComments.filter((item) => {
return item.node.value.includes(comments);
});
return res[0] || null;
}
return null;
};
const getParamsFromComment = (commentNode, options) => {
const commentStr = commentNode.node.value;
if (commentStr.indexOf(options.commentParam) === -1) {
return null;
}
try {
return commentStr.slice(commentStr.indexOf("{"), commentStr.indexOf("}") + 1);
} catch {
return null;
}
};
const insertTrackerBeforeReturn = (path, param, state) => {
//blockStatement
const bodyPath = path.get("body");
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
if (bodyPath.isBlockStatement()) {
//get returnStatement, by body of blockStatement
const returnPath = bodyPath.get("body").slice(-1)[0];
if (returnPath && returnPath.isReturnStatement()) {
returnPath.insertBefore(ast);
} else {
bodyPath.node.body.push(ast);
}
} else {
ast = template.statement(`{ ${state.importTackerId}(${param}); return BODY; }`)({BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};
const checkImport = (programPath, trackPath) => {
let importTrackerId = "";
programPath.traverse({
ImportDeclaration(path) {
const sourceValue = path.get("source").node.value;
if (sourceValue === trackPath) {
const specifiers = path.get("specifiers.0");
importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});
if (!importTrackerId) {
importTrackerId = addDefault(programPath, trackPath, {
nameHint: programPath.scope.generateUid("tracker"),
}).name;
}
return importTrackerId;
};
module.exports = declare((api, options) => {
return {
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|FunctionExpression|ClassMethod": {
enter(path, state) {
let nodeComments = path;
if (path.isExpression()) {
nodeComments = path.parentPath.parentPath;
}
// 获取leadingComments
const leadingComments = nodeComments.get("leadingComments");
const paramCommentPath = hasTrackerComments(leadingComments,options.commentsTrack);
// 如果有注释,就插入函数
if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;
const param = getParamsFromComment(paramCommentPath, options);
insertTrackerBeforeReturn(path, param, state);
}
},
},
},
};
});
代码难点讲解:
getParamsFromComment 函数的逻辑是检查代码是否含有注释 _tracker
,如果有的话,再检查这一行注释中是否含有参数,最后再将埋点插入函数。
在检查是否含有参数的过程中,用到了插件参数 commentParam
。它表示如果注释中含有该字符串,那后面的内容就是参数了。获取参数的方法也是简单的字符串切割。如果没有获取到参数,就一律返回 null。
获取参数的复杂程度取决于,先前是否就有一个规范。并且编写代码时严格按照规范执行。 像我这里的规范是埋点参数
commentParam
和埋点标识符_tracker
必须放在一行,并且参数需要是对象的形式。即然是对象的形式,那这一行注释中就不允许就其他的大括号符号"{}"
。 遵从了这个规范,获取参数的过程就变的很简单了。当然你也可以有自己的规范。
import "./index.css";
//##箭头函数
//_tracker,_trackerParam={name, age:18}
const test1 = () => {
const name = "gongfu2";
};
const test1_2 = () => {};
当传递变量时,插入的参数确实用了变量,但是引用变量却在变量声明之前,这肯定不行,得改改。需要将埋点函数插入到函数体的后面,并且是 returnStatement
的前面,这样就不会有问题了。
这里将 insertTracker
改成了 insertTrackerBeforeReturn
。
其中关键的逻辑是判断是否是一个函数体,
- 如果是一个函数体,就判断有没有
return
语句,- 如果有
return
,就放在return
前面 - 如果没有
return
,就放在整个函数体的后面
- 如果有
- 如果不是一个函数体,就直接生成一个函数体,然后将埋点函数放在
return
的前面
参数放在局部作用域中
这个功能的关键就是读取当前作用域中的变量。
在写代码之前,来定一个前提:当前作用域的变量名也和注释中参数标识符一致,也是_trackerParam。
源代码
import "./index.css";
//##箭头函数
//_tracker,_trackerParam={name, age:18}
const test1 = () => {
const name = "gongfu2";
};
const test1_2 = () => {};
//函数表达式
//_tracker
const test2 = function () {
const age = 1;
_trackerParam = {
name: "gongfu3",
age,
};
};
const test2_1 = function () {
const age = 2;
_trackerParam = {
name: "gongfu4",
age,
};
};
编写插件
const { declare } = require("@babel/helper-plugin-utils");
const { addDefault } = require("@babel/helper-module-imports");
const { template } = require("@babel/core");
//judge if there are trackComments in leadingComments
const hasTrackerComments = (leadingComments, comments) => {
if (!leadingComments) {
return false;
}
if (Array.isArray(leadingComments)) {
const res = leadingComments.filter((item) => {
return item.node.value.includes(comments);
});
return res[0] || null;
}
return null;
};
const getParamsFromComment = (commentNode, options) => {
const commentStr = commentNode.node.value;
if (commentStr.indexOf(options.commentParam) === -1) {
return null;
}
try {
return commentStr.slice(commentStr.indexOf("{"), commentStr.indexOf("}") + 1);
} catch {
return null;
}
};
const insertTrackerBeforeReturn = (path, param, state) => {
//blockStatement
const bodyPath = path.get("body");
let ast = template.statement(`${state.importTackerId}(${param});`)();
if (param === null) {
ast = template.statement(`${state.importTackerId}();`)();
}
if (bodyPath.isBlockStatement()) {
//get returnStatement, by body of blockStatement
const returnPath = bodyPath.get("body").slice(-1)[0];
if (returnPath && returnPath.isReturnStatement()) {
returnPath.insertBefore(ast);
} else {
bodyPath.node.body.push(ast);
}
} else {
ast = template.statement(`{${state.importTackerId}(${param}); return BODY;}`)({BODY: bodyPath.node });
bodyPath.replaceWith(ast);
}
};
const checkImport = (programPath, trackPath) => {
let importTrackerId = "";
programPath.traverse({
ImportDeclaration(path) {
const sourceValue = path.get("source").node.value;
if (sourceValue === trackPath) {
const specifiers = path.get("specifiers.0");
importTrackerId = specifiers.get("local").toString();
path.stop();
}
},
});
if (!importTrackerId) {
importTrackerId = addDefault(programPath, trackPath, {
nameHint: programPath.scope.generateUid("tracker"),
}).name;
}
return importTrackerId;
};
module.exports = declare((api, options) => {
return {
visitor: {
"ArrowFunctionExpression|FunctionDeclaration|FunctionExpression|ClassMethod": {
enter(path, state) {
let nodeComments = path;
if (path.isExpression()) {
nodeComments = path.parentPath.parentPath;
}
// 获取leadingComments
const leadingComments = nodeComments.get("leadingComments");
const paramCommentPath = hasTrackerComments(leadingComments,options.commentsTrack);
// 如果有注释,就插入函数
if (paramCommentPath) {
//add Import
const programPath = path.hub.file.path;
const importId = checkImport(programPath, options.trackerPath);
state.importTackerId = importId;
//check if have tackerParam
const hasTrackParam = path.scope.hasBinding(options.commentParam);
if (hasTrackParam) {
insertTrackerBeforeReturn(path, options.commentParam, state);
return;
}
const param = getParamsFromComment(paramCommentPath, options);
insertTrackerBeforeReturn(path, param, state);
}
},
},
},
};
});
代码难点讲解:
path.scope.hasBinding() 这个函数的逻辑是先判断当前作用域中是否有变量 _trackerParam
,有的话,就获取该声明变量的初始值。然后将该变量名作为 insertTrackerBeforeReturn
的参数传入其中。
小结
讲了如何给埋点的函数添加参数,参数可以写在注释里,也可以写在布局作用域中。支持动态传递,非常灵活。
在create-reate-app中使用我们手写的babel插件
要在create-react-app项目中使用自定义Babel插件,你需要执行以下步骤:
- 在项目根目录下创建一个config-overrides.js文件(如果还没有的话)。
- 在config-overrides.js文件中,导出一个函数来重写Create React App的Webpack配置。
- 使用require加载你的自定义Babel插件。
- 应用插件到Babel的配置中。
以下是一个config-overrides.js的示例,它演示了如何加载和使用自定义Babel插件:
// config-overrides.js
const path = require('path');
module.exports = function override(webpackConfig, env) {
// 确保Babel插件路径是正确的
const babelPlugin = path.resolve(__dirname, 'path-to-your-babel-plugin.js');
// 修改webpack配置
webpackConfig.module.rules.forEach(rule => {
// 针对.js文件的规则
if (rule.test.toString() === '/\\.js$/') {
// 确保Babel配置是一个数组
const babelLoader = rule.use[0];
if (babelLoader.options) {
babelLoader.options.plugins = [
...(babelLoader.options.plugins || []),
require(babelPlugin), // 加载自定义插件
];
}
}
});
return webpackConfig;
};
请注意,自定义Babel插件应该是一个符合Babel插件API的标准插件,并且需要确保它与Create React App的Babel版本兼容。
最后,重新启动开发服务器以使更改生效。