AST的api详解
AST的api详解

AST的api详解

AST的API详解

JavaScript(以下简称JS)的语法是非常灵活的,如果直接处理JS 代码来做混或者还原,那无疑尽很麻烦的,要考虑的情况太多了,而且容易出错。但是把JS 代码转换成抽象语法树(以下简称 AST) 以后,一切就变得简单了。在编译原理里,从源码到机器码的过程,中间还需要经过很多步骤。比如,源码通过词法分析器变为记号序列,再通过语法分析器变为AST,再通过语义分析器,一步步往下编译,最后变成机器码。所以,AST 实际上是一个概念性的东西,当实现了词法分析器和语法分析器,就能把不同的语言解析成 AST。

要想把JS 代码转换成AST,可以自己写代码去实现,也可以使用现成的解析库。当使用的解析库不一样的时候,生成的AST会有所区别。本书采用的是 Babel,是一个 nodejs库。在用AST 自动化处理S 代码前肯定需要对 AST相关的 API有一定的了解,这也是本章着重介绍的内容。

官网:babel-handbook/translations/zh-Hans/plugin-handbook.md at master · jamiebuilds/babel-handbook (github.com)

一.AST入门

1.AST的基本结构

JS代码解析成AST 以后,其实就相当于是 json 数据。经过 Babel 解析以后,通常把里面的一些元素叫做节点 (Node),同时 Babel 也提供了很多方法去操作这些节点。本小节以个案例来说明 AST 的基本结构,代码如下:

let obj = {
    name: 'xiaojianbang',
    add: function (a, b) {
        return a + b + 1000;
    },
    mul: function (a, b) {
        return a * b + 1000;
    },
};

本章后续内容中,如果没有特别说明,均以此代码为例。将上述代码拿到网页中进行解析 (AST Explorer: https://astexplorer.net/), 如图8-1所示。在网页的上边,先选择JavaScript 语言以及babel/parser 的解析方式。此时在网页的右上角,会显示当前选择的解析方式。网页的左边是输入JS 代码的地方。网页的右边是解析以后的 AST。当然该网页不单单可以解析JS 代码,还可以解析 HTML、CSS、Lua、PHP 等。

image-20230830112105992

解析以后的AST 有很多层级,这些层级互为父子节点。接下来,逐级分析一下这些节点:

"body": [{
    "type": "VariableDeclaration",
    ...
    "declarations": [{...}],
    "kind": "let"
}]
//注意,出于简化目的移除了某些属性    

字符串形式的 type 字段表示节点的类型,比如上述代码中的VariableDeclaration。每一种类型的节点定义了一些附加属性,用来进一步描述该节点类型。

VariableDeclaration表示这是一个变量声明语句。kind 表示变量声明语句所使用的关键字。那么,declarations自然就是指声明的具体的变量。declarations 是一个数组,因为一个let 关键字可以同时声明多个变量,比如,let a, 当前就只声明了一个变量 obj,所以 declarations 只有一个成员。再来查看 declarations 中的内容:

{
    "type": "VariableDeclarator",   
    ...
    "id":{
        "type": "Indentifier",
        ...
        "name":"obj"
    },
    "int": {...}
}

...

2.代码的基本结构

把原始代码保存成一个名为demo.js的文件,注意,需要保存成utf-8编码的格式。另外新建一个文件,用来解析demo.js

AST解析转换代码的基本结构如下所示:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

//在这里对AST进行一系列的操作

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
  1. fs 库用来读写本地文件,require 之后赋值给了 fs
  2. @babel/parser 库用来将 JS 代码转换成 AST,require 之后赋值给了 parser
  3. @babel/traverse 库用来遍历AST中的节点,require 之后把其中的 default 赋值给了traverse
  4. @babel/types 库用来判断节点类型和生成新的节点等,require 之后赋值给了 t
  5. @babel/generator 库用来把 AST 转换成JS 代码,require 之后把其中的 default 赋值给了 generator

从上述代码中,可以看出 AST 处理JS 文件的基本步骤,

  • 读取JS 文件-->
  • 解析成 AST-->
  • 对节点进行一系列的增删改查-->
  • 生成JS 代码-->
  • 保存到新文件中

后续的内容中,如果没有特别说明,就以这个结构为准,代码只贴出中间对 AST 有操作的部分。

二、Babel中的组件

1.parser与generator

这两个组件的作用刚好是相反的。parser 组件用来将JS代码转换成AST,generator用来将 AST 转换成JS 代码。

使用let ast = parser.parse(jscode);即可完成JS代码转换到AST 的过程。这时候把 ast 输出来,就是跟网页中解析出来的一样的结构。

输出前通常先使用JSON.stringify把对象转 json 数据。比如,console.log(JSON.stringify(ast,null,2));。

另外,parser的 parse 方法,其实是有第二个参数的。

let ast = parser.parse(jscode,{
sourceType: "module",
});

sourceType 默认是 script。当解析的JS代码中,含有import 、export等关键字的时候,需要指定 sourceType 为 module。不然会有如下报错:

Syntaxrror: 'import' and 'export' may appear only with 'sourcelype: "module"
(1:0)

使用let code = generator(ast).code; 即可把AST转换为JS代码。

image-20230830145043118

generator 返回的是一个对象,其中的 code 属性才是需要的代码。

同时,generator 的第二个参数接收一个对象,可以设置一些选项,来影响输出的结果。

完整的选项介绍,可在 Babel 官方文档中查看@babel/generator · Babel (babeljs.io),本书只介绍其中的一部分。

let code = generator(ast, {
    retainLines: false, 
    comments: false, 
    compact: true
}).code;
console.log(code);
  1. retainLines 表示是否使用与源代码相同的行号,默认为 false,也就是输出的是格式化后的代码

  2. comments 表示是否保留注释,默认为 true

  3. compact 表示是否压缩代码,与其相同作用的选项还有 minified、concise 只不过压缩的程度不一样,minified 压缩的最多,concise 压缩的最少

    let code = generator(ast, {
    retainLines: false,
    comments: false,
    compact: true,
    minified: true
    }).code;
  4. 多个选项之间可以配合使用。

image-20230830150115566

2.traverse与visitor

traverse组件用来遍历AST,简单的说就是把AST上的各个节点都走一遍。

但是单纯的把节点都走一遍,没有什么意义。所以,traverse 需要配合visitor 使用。

visitor 其实就是一个对象,里面可以定义一些方法,用来过滤节点。说的有点抽象,用 1.1小节中的原始代码,来试一下 traverse和visitor 的效果。

let visitor = {};
visitor.FunctionExpression = function(path){
    console.log("xiaojianbang");
};
traverse(ast,visitor);
/*输出
xiaojianbang
xiaojianbang
*/

先是声明对象,这个对象的名字随意,然后给对象增加了一个名为 FunctionExpression的方法,这个名字是需要遍历的节点类型,注意大小写,不能写错。

traverse 会遍历所有的节点,当节点类型为FunctionExpression 时,调用visitor 中相应的方法。如果想要处理其他节点类型,比如Identifier,可以在 visitor 中继续定义方法,以Identifier 命名即可。

visitor中的方法接收一个参数,traverse 在遍历的时候,会把当前节点的path对象传给它。注意,传过来的是 path 对象而非节点(Node)。path 对象与节点的区别,会在 后续小节中介绍。

最后把visitor作为第二个参数传到 traverse里面。而传给 traverse 的第一个参数是整个ast。这段代码的意思是,从头开始遍历ast中的所有节点,过滤出FunctionExpression节点,执行相应的方法。在 1.1小节的原始代码中,有两个FunctionExpression 节点。因此,会输出两次xiaojianbang。
上述定义 visitor 的方式,等价于下述三种方式,读者们可根据个人习惯,自行选择一种使用。最常用的是visitor2 这种形式。

const visitor1 = {
    FunctionExpression: function(path) {
        console.log("xiaojianbang");
    }
};

const visitor2 = {
    FunctionExpression(path){
        console.log("xiaojianbang");
    }
};

const visitor3 = {
    FunctionExpression:{
        enter(path){
            console.log("xiaojianbang");  
        }
    }
};

image-20230830171756173

代码示例:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

let visitor = {};
visitor.FunctionExpression = function(path){
    console.log("xiaojianbang");
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

在visitor3中,看到有一个enter 函数,这又是什么东西呢?在遍历节点过程中,实际上有两次机会来访间一个节点,即进入节点时 (enter) 与退出节点时(exit)。以 1.1小节中的add 函数为例,节点的遍历过程可描述如下:

image-20230830172845002

image-20230830173021266

进入 FunctionExpression
    进入 Identifier(params[0])走到尽头
    退出 Identifier(params[0])
    进入 Identifier(params[1])走到尽头
    退出 Identifier(params[1])
    进入 Blockstatement(body)
        进入 Returnstatement(body)
            进入 BinaryExpression(argument)
                进入 BinaryExpression(left)
                    进入 Identifier(left)走到尽头
                    退出 Identifier(left)
                    进入 Identifier(right)走到尽头
                    退出 Identifier(right)
                退出 BinaryExpression(left)
                进入NumericLiteral(right)走到尽头
                退出 NumericLiteral(right )
            退出BinaryExpression(argument)
        退出ReturnStatement(body)
    退出BlockStatement(body)
退出FunctionExpression

正确的选择节点处理时机,有助于提高代码效率。

可以看出 traverse 是一个深度优先的遍历过程。

事实上,深度优先搜索属于图算法的一种,英文缩写为DFS即Depth First Search.其过程简要来说是对每一个可能的分支路径深入到不能再深入为止,而且每个节点只能访问一次.

因此,如果存在父子节点,那么 enter 的处理时机是先处理父节点,再处理子节点。

而exit 的处理时机是先处理子节点,再处理父节点。

读者们可与AST explorer网站解析后的AST 结构进行比对,会更清晰的理解这个过程。

traverse 默认就是在enter 时候处理,如果要在 exit 时候处理,必须在 visitor 中写明。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor3 = {
    FunctionExpression: {
        enter(path) {
            console.log("xiaojianbang enter");
        },
        exit(path) {
            console.log("xiaojianbang exit");
        }
    }
};
traverse(ast, visitor3);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
因为有两个函数,所以各输出两次
xiaojianbang enter
xiaojianbang exit
xiaojianbang enter
xiaojianbang exit
*/

还可以把方法名用|分割成FunctionExpression|BinaryExpression形式的字符串,把同一个函数应用到多个节点。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    "FunctionExpression|BinaryExpression"(path){
        console.log("xiaojianbang");
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
xiaojianbang
xiaojianbang
xiaojianbang
xiaojianbang
xiaojianbang
xiaojianbang
*/

也可以把多个函数应用于同一个节点。原先把一个函数赋值给enter或者exit,现在改为函数的数组即可,会按顺序依次执行。示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

function func1(path){
    console.log('func1');
}
function func2(path){
    console.log('func2');
}
const visitor = {
    FunctionExpression: {
        enter: [func1, func2]
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
func1
func2
func1
func2
*/

traverse 并非必须从头遍历,可在任意节点向下遍历。以 1.1小节中的原始代码为例,现在想要把代码中所有函数的第一个参数改为 x,代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const updateParamNameVisitor = {
    Identifier(path) {
        if (path.node.name === this.paramName) {
            path.node.name = "x";
        }
    }
};
const visitor = {
    FunctionExpression(path) {
        const paramName = path.node.params[0].name;
        path.traverse(updateParamNameVisitor, {
            paramName
        });
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

我们在visitor里打印下paramName看下:

image-20230830175022740

这段代码先用traverse根据 visitor 去遍历所有节点。

当得到 FunctionExpression 节点时,用 path.traverse 根据 updateParamNameVisitor 去遍历当前节点下的所有子节点,然后修改与函数第一个参数相同的标识符。

在使用 path.traverse 时,还可以额外传入一个对象,在对应的 visitor 中用 this 去引用它。其中 path.node 才是当前节点,所以 path.node.params[0].name可以取出函数的第一个参数名。

3.types 组件

该组件主要用来判断节点类型、生成新的节点等。判断节点类型的方法很简单,例如t.isIdentifier(path.node),它等同于 path.node. type==="Identifier"。还可以在判断类型的同时附加条件,示例如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    enter(path) {
        if (
            path.node.type === "Identifier" &&
            path.node.name === "n"
        ){
            path.node.name = "x";
        }
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

上述代码用来把标识符 n改为x,这是官方手册中的案例,在实际修改中还需要考虑标识符的作用域。在这个案例中,visitor 没有做任何过滤,遍历到任何一个节点就调用enter函数,所以要判断类型为 Identifier,并且 name 的值为n,才修改为x。这个案例可以等价的写为:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    enter(path) {
        if (
            t.isIdentifier(path.node, {name: 'n'})
        ){
            path.node.name = "x";
        }
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

如果要判断其它类型,只需把 is 后面的类型更改即可。这些方法还有一种断言式的版本,当节点不符合要求,会抛出异常而不是返回 true 或false

t.assertBinaryExpression (maybeBinaryExpressionNode);
t.assertBinaryExpression (maybeBinaryExpressionNode,{operator: "*"});

从上文的叙述中,可以看出 types 组件中用于判断节点类型的函数是可以自己实现的,而且也不是很麻烦。

所以 types 组件最主要的功能是可以方便的生成新的节点。尝试一下把1.1小节中的原始代码,用 types 组件来生成。这当然需要对 AST 的结构有一定的了解,对 AST 结构还不是很熟悉的读者们,再回顾一下 1.1小节的内容。
Babel 中的API有很多,不可能全部记住API的用法,不然还要文档和代码提示做什么?同时本书希望读者们掌握的是获取知识的方法,而不是答案。所以,一定要学会看代码提示.在原始代码中最开始是一个变量声明语句,类型为VariableDeclaration,因此可以用t.variableDeclaration去生成它。在vscode中输入t.variableDeclaration, 鼠标悬停在variableDeclaration 上,会出现代码提示。当然也可以按住 ctrl键不放,鼠标左键单击 variableDeclaration,会跳转到一个 ts 后缀的文件中,里面有这么一句代码:

export function variableDeclaration(kind: "var"|"let"|"const",declarations:
Array<VariableDeclarator>): VariableDeclaration;

来解释一下这段代码。最后一个冒号后面,表示这个函数的返回值类型。小括号里面的冒号前面,很明显是 VariableDeclaration 节点的属性。小括号里面的冒号后面,表示该参数允许传的类型。Array 表示这个参数是一个数组。因此,变量声明语句的生成代码,可以写为:

let loaclAst = t.variableDeclaration('let', [varDecj]);
let code = generator(loaclAst).code;
console.log(code);

那么这里的varDec 又该如何生成呢?这里需要生成一个VariableDeclarator 节点。表示变量声明的具体的值。

BinaryExpression 类型: 操作符两边都是 Literal 类型的节点

代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

let a = t.identifier('a');
let b = t.identifier('b');
let binExpr2 = t.binaryExpression("+", a, b);
let binExpr3 = t.binaryExpression("*", a, b);
let retSta2 = t.returnStatement(t.binaryExpression("+", binExpr2, t.numericLiteral(1000)));
let retSta3 = t.returnStatement(t.binaryExpression("+", binExpr3, t.numericLiteral(1000)));
let bloSta2 = t.blockStatement([retSta2]);
let bloSta3 = t.blockStatement([retSta3]);
let funcExpr2 = t.functionExpression(null, [a, b], bloSta2);
let funcExpr3 = t.functionExpression(null, [a, b], bloSta3);
let objProp1 = t.objectProperty(t.identifier('name'), t.stringLiteral('xiaojianbang'));
let objProp2 = t.objectProperty(t.identifier('add'), funcExpr2);
let objProp3 = t.objectProperty(t.identifier('mul'), funcExpr3);
let objExpr = t.objectExpression([objProp1, objProp2, objProp3]);
let varDec = t.variableDeclarator(t.identifier('obj'), objExpr);
let loaclAst = t.variableDeclaration('let', [varDec]);
let code = generator(loaclAst).code;
console.log(code);

// let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

/*
let obj = {
  name: "xiaojianbang",
  add: function (a, b) {
    return a + b + 1000;
  },
  mul: function (a, b) {
    return a * b + 1000;
  }
};
*/

在 JS 代码的处理转换过程中,生成的新节点一般会添加或替换到已有的节点中,在3.2 小节中会详细介绍。
在上述案例中,用到了 StringLiteral、NumericLiteral,同时在 Babel 中还定义了一些其他的字面量。

export function nullliteral(): NuliLiteral;
export function booleanLiteral(value: boolean): BooleanLiteral;
export function regExpLiteral(pattern: string, flags?: any): RegExpLiteral;

因此,不同的字面量需要调用不同的方法去生成。当生成比较多的字面量的时候,会很麻烦。其实在 Babe中还提供了 valueToNode 方法。

export function valueToNode(value: undefined): Identifier
export function valueToNode(value:boolean): BooleanLiteral
export function valueToNode(value: null): NuliLiteral
export function valueToNode(value: string): StringLiteral
export function valueToNode(value: number): Numericliteral | BinaryExpression | UnaryExpression
export function valueToNode(value: RegExp): RegExpLiteral
export function valueTolode(value: ReadonlyArray<undefined | boolean | null | string | number | RegExp | object>): ArrayExpression
export function valueToNode(value: object): ObjectExpression
export function valueToNode(value: undefined | boolean | null | string | number| RegExp | object): Expression

由此可以看出 valueToNode 方法可以很方便的生成各种类型。除了原始类型undefined、null、string、number、boolean,还可以是对象类型 RegExp、ReadonlyArray、object,示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

let loaclAst = t.valueToNode([1, "2", false, null, undefined, /\w\s/g, {x: '1000', y: 2000}]);
let code = generator(loaclAst).code;
console.log(code);

let code1 = generator(ast).code;
console.log(code1);
/*
[1, "2", false, null, undefined, /\w\s/g, {
  x: "1000",
  y: 2000
}]

let obj = {
  name: 'xiaojianbang',
  add: function (a, b) {
    return a + b + 1000;
  },
  mul: function (a, b) {
    return a * b + 1000;
  }
};

*/

结合 ts 文件中的定义和AST 解析后的json,可以迅速掌握这些API的使用方法。

在前面的介绍中提到 Babel 解析后的 AST,其实就是一个 json。因此,也可以自己按照AST 的结构来构造一个 json,以此来生成想要的代码。只不过比使用 types 组件麻烦许多,还是推荐使用 types 组件来生成。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);
//////////////////////////////////////////////////
let obi  = [];
obj.type m BinaryExpression';
obj.left = {type:"NumericLiteral",value: 10001};
obj.operator = '/';
obj.right = {type: 'NumericLiteral',value: 2000};
let code = generator(obj).code;
console.log(code);
//输出 1000 /2000

三、Path对象详解

1.Path与Node的区别

Path包含Node

以一个案例来清楚的说明问题,下面这段代码在之前的小节中见过,只不过多了两句代码,处理的依然是 1.1小节中的原始代码。其中path.stop()用来停止遍历节点。在 Babel中 path.skip()效果与之类似。

下面这段代码的意思是,当在函数表达式中追历到一个 Identifier 节点的时候,就输出 path,然后停止遍历。根据 traverse 遍历规则,遍历到的这个Identifier 节点肯定是params[0]。来观察一下输出的结果:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const updateParamNameVisitor = {
    Identifier(path) {
        if (path.node.name === this.paramName) {
            path.node.name = "x";
        }
        console.log(path);
        path.stop();
    }
};
const visitor = {
    FunctionExpression(path) {
        const paramName = path.node.params[0].name;
        path.traverse(updateParamNameVisitor, {
            paramName
        });
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
<ref *2> NodePath {
  contexts: [
      ...
  ],
  state: { paramName: 'a' },
  opts: { Identifier: { enter: [Array] }, _exploded: true, _verified: true },
  _traverseFlags: 0,
  skipKeys: null,
  parentPath: <ref *1> NodePath {
     ...
   }
   ,
  container: [
    ...
  ],
  listKey: 'params',
  key: 0,
  node: Node {
    type: 'Identifier',
    start: 59,
    end: 60,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: 'a'
    },
    name: 'x'
  },
  type: 'Identifier',
  parent: Node {
   ...
  },
  hub: undefined,
  data: null,
  context: TraversalContext {
    ...
  },
  scope: <ref *3> Scope {
    ...
}
...
*/

显而易见,path.node 就能取出 Identifier 所在的 Node 对象,该对象与AST Explorer网页中解析出来的 AST 节点结构一致。简单来说,节点是生成JS 代码的原料,只是 Path中的一部分。Path 是一个对象,用来描述两个节点之间连接。Path 除了具有上述显示的这些属性以外,还包含添加、更新、移动和删除节点等有关的很多方法。

2.Path中的方法

在理清了 Path 与 Node 的关系以后,再来学习 Path 中的方法就比较容易了。以下代码均以处理 1.1小节中的原始代码为例。

1.获取子节点/Path

为了得到 AST 节点的属性值,一般先访问到该节点,然后利用 path.node.property 方法获取属性。

const visitor = {
BinaryExpression(path) {
    console.log(path.node.left);
    console.log(path.node.right);
    console.log(path.node.operator);
    }
};
traverse(ast, visitor);
/*
    Node {type:'BinaryExpression', ..., left: Node}
    Node {type:'NumericLiteral', ..., extra: {...}}
    +
    Node {type:'Identifier', ..., name:'a'}
    Node {type:'Identifier', ..., name:'b'}
    +
*/

通过上述方法,获取到的是None或者具体 属性值,Node是用不了Path相关方法的。

如果想要获取到该属性的 Path,就需要使用 Path 对象的 get 方法,传递的参数为 key (其实就是该属性名的字符串形式,后续内容中会详细介绍什么是 key)。

如果是多级访问,那么以点连接多个 key。

const visitor = {
BinaryExpression (path) {
    console.log(path.get('left.name'));
    console.log(path.get('right'));
    console.log(path.get('operator'));
    }
};
traverse(ast,visitor);
/*会输出n个NodePath
    NodePath {parent: Node, hub: undefined,contexts: Array(0),data: null, _traverseFlags: 0}
*/

从上述代码中,可以看到任何形式的属性值,通过 Path 对象的 get 方法去获取,都会包装成Path 对象再返回来。

但是,像 operator、name 这一类的属性值,其实没有必要包装成Path对象。

2.判断Path类型

再回顾一下3.1小节中输出的 Path 对象,就会发现最后有一个 type 属性,它基本上与 Node 中的 type 一致。Path 对象提供相应的方法来判断自身类型,使用方法与 types 组件差不多,只不过 types 组件判断的是 Node。

解析下原始代码中的第一个函数中的二项式,

left 是a + b,right 是1000。

因此,下面的代码执行以后的结果为false、true、报错。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    BinaryExpression(path) {
        console.log(path.get('left').isIdentifier());
        console.log(path.get('right').isNumericLiteral({
                value: 1000
            }));
        path.get('left').assertIdentifier();
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*输出
false
true
报错
*/

3.节点转代码

写代码并不是一蹴而就,当代码比较复杂的时候,就需要动态调试。在 vscode 中调试只需鼠标左键单击行号,即可下断点。启动调试就会在断点处断下来,与调试普通 JS 文件无异。还可以适时地插入 console 来排查错误。也可以基于当前节点生成代码来排查错误也就是说,很多时候需要在执行过程中把部分节点转为代码,而不是在最后才把整个AST转成代码。generator 组件也可以把 AST 中的一部分节点转成代码,这对节点遍历过程中的调试很有帮助。

示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    FunctionExpression(path) {
        console.log(generator(path.node).code);
        //console.log( path.toString() );
        //console.log( path +  '' );
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
    function (a, b) {
      return a + b + 1000;
    }
    function (a, b) {
      return a * b + 1000;
    }
*/

Path 对象复写了 Object 的 toString 方法。

在 Path 对象的 toString 方法中,去调用generator 组件把节点转为代码。

因此,也可以用 path.toString()把节点转为字符串。当然也可以用 path + '' 来隐式转成字符串。

4.替换节点属性

与获取节点属性方法相同,只是改为赋值。但也不是随意替换,需要注意替换的类型要在允许的类型范围内。因此需要熟悉 AST 的结构。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    BinaryExpression(path) {
        path.node.left = t.identifier("x");
        path.node.right = t.identifier("y");
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

5.替换整个节点

Path 对象中与替换相关的方法有 replaceWith、replaceWithMultiple、replaceInline、replaceWithSourceString
先来看看 replaceWith 的用法,该方法用来节点换节点,并且是一换一。示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    BinaryExpression(path) {
        path.replaceWith(t.valueToNode('xiaojianbang'));
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
    let obj = {
      name: 'xiaojianbang',
      add: function (a, b) {
        return "xiaojianbang";
      },
      mul: function (a, b) {
        return "xiaojianbang";
      }
    };
*/

再来看看 replaceWithMultiple 的用法,该方法也是用来节点换节点,只不过是多换一。示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    ReturnStatement(path) {
        path.replaceWithMultiple([
                t.expressionStatement(t.stringLiteral("xiaojianbang")),
                t.expressionStatement(t.numericLiteral(1000)),
                t.returnStatement(),
            ]);
        path.stop();
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
    let obj = {
      name: 'xiaojianbang',
      add: function (a, b) {
        "xiaojianbang";
        1000;
        return;
      },
      mul: function (a, b) {
        return a * b + 1000;
      }
    };
*/

上述代码中有两个注意点要特别说明,第一点是当表达式语句单独一行时(没有赋值),最好用expressionStatement 包裹一下。

第二点是替换后的节点,traverse 也是能遍历到的。因此替换的时候要小心,不要造成不合理的递归调用。

比如上述代码,把 return 语句进行替换,但是替换的语句里又有 return 语句,这就死循环了。

解决方法是加入 path.stop()替换完事就停止遍历当前节点和后续的子节点。

再来看看 replaceInline 的用法,该方法接收一个参数。

如果参数不为数组,那么replaceInline 等同于 replaceWith。

如果参数是一个数组,那么replaceInline 等同于replaceWithMultiple,当然数组成员必须都是节点。示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

const visitor = {
    StringLiteral(path) {
        path.replaceInline(
            t.stringLiteral('Hello AST!'));
        path.stop();
    },
    ReturnStatement(path) {
        path.replaceInline([
                t.expressionStatement(t.stringLiteral("xiaojianbang")),
                t.expressionStatement(t.numericLiteral(1000)),
                t.returnStatement(),
            ]);
        path.stop();
    }
};
traverse(ast, visitor);

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

上述代码中,visitor 中的函数也要加入 path.stop()原因和之前介绍的等同。

最后看看 replaceWithSourceString 的用法,该方法用字符串源码替换节点。

比如,想要把 1.1小节的原始代码中的函数改为闭包形式,示例代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    ReturnStatement(path) {
        let argumentPath = path.get('argument');
        argumentPath.replaceWithSourceString(
            'function(){return ' + argumentPath + '}()'
        );
        path.stop();
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
    let obj = {
      name: 'xiaojianbang',
      add: function (a, b) {
        return function () {
          return a + b + 1000;
        }();
      },
      mul: function (a, b) {
        return a * b + 1000;
      }
    };
*/

先是遍历 ReturnStatement,然后通过 Path 的get 方法去获取子Path,这样才能调用Path 相关的方法去操作argument 节点。

同时,replaceWithSourceString 替换后的节点也会被解析,也就是说会被 traverse 遍历到。

因为里面也有 return 语句,所以需要加上path.stop()。上述代码中还用到了节点转代码,只不过是隐式转换。

凡是需要修改节点的操作,都推荐使用 Path 对象的方法。因为当调用一个修改节点的方法后,Babel会更新 Path 对象。

6.删除节点

示例代码如下:

const visitor = {
    EmptyStatement(path){
        path.remove()
    }
}

EmptyStatement 指的是空语句,就是多余的分号。使用 path.remove()删除当前节点。

7.插入节点

想要把节点插入到兄弟节点中,可以使用 insertBeforeinsertAfter 分别在当前节点的前后插入节点。代码如下:

traverse(ast, {
    ReturnStatement(path){
        path.insertBefore(t.expressionStatement(t.stringliteral("Before")));
        path.insertAfter(t.expressionStatement(t.stringLiteral("After")));
    }
};

/*
    let obj = {
        nare:'xiaojianbang',
        add: function(a,b){
            "Before";
            return a + b +1000;
            "After";
        },
        mul: function (a, b){
            "Before";
            return a * b +1000;
            "After";
        }
    };
*/

在上述代码中,如果只想操作某一个函数中的 ReturnStatement,那可以在 visitor 的函数中进行判断,不符合要求的 return 即可。注意,使用 path.stop() 是做不到的。

3.父级Path

再回顾下 3.2 小节中输出的 Path 对象。可以看到有 parentPath 和 parent 两个属性。

其中parentPath类型为NodePath,所以它是父级 Path。parent 类型为 Node,所以它是父节点。

那么只要获取到父级 Path,就可以愉快地调用 Path 对象的各种方法去操作父节点。

父级Path 的获取可以使用 pathparentPath

1.parentPath与 parent 的关系

path.parentPath.node 等价于 path.parent,也就是说parent 是 parentPath 中的部分。

2.path.findParent()

有时候需要从一个路径向上遍历语法树,直到满足相应的条件。这时候可以使用 Path对象的 findParent 方法,演示例子如下:

traverse(ast, {
    ReturnStatement (path) {
        console.log(path.findParent((p) => p.isObjectExpression()));
        //path.findParent(function (p)(return p.isObjectExpression()));
    }
});

Path 对象的findParent 方法接收一个回调函数,在向上遍历每一个父级 Path 时,会调用该回调函数,并传入对应的父级 Path 对象作为参数。

当该回调函数返回真值时,则将对应的父级Path 返回。上述代码遍历 ReturnStatement,然后向上找父级 Path,当找到 Path对象类型为0bjectExpression 的时候,就返回该Path 对象。

3.path.find()

这个方法用的不多,使用方法与 findParent一致,只不过find 方法查找的范围包含当前节点,而findParent 不包含。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    ObjectExpression(path) { 
        console.log( path.find((p) => p.isObjectExpression()) );
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

4.path.getFunctionParent

向上查找与当前节点最接近的父函数path.getFunctionParent,返回的也是Path对象。

5.path.getStatementParent

向上遍历语法树,直到找到语句父节点。比如,声明语句、return 语句、if 语句、witch语句、while 语句等等。返回的也是 Path 对象。注意,该方法从当前节点开始找起,因此,如果想要找到|return 语句的父语句,就需要从 parentPath 中去调用,代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    ReturnStatement(path) { 
        console.log( path.parentPath.getStatementParent() );
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});

6.父级Path的其他方法

其他方法的使用与之前介绍的类似,如:

  • 替换父节点path.parentPath.replaceWith(None)
  • 删除父节点path.parentPath.remove()
  • ...

4.同级Path

在介绍同级 Path 之前,需要先介绍下容器 (container)。什么是容器呢? 用三个例子来说明问题,依然是以处理 1.1小节中的原始代码为例。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    ReturnStatement(path) {
        console.log(path);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
    NodePath {
        parent: Node {...},
        ...
        parentPath: NodePath {...},
        ...
        container: {
            Node {
                type: "ReturnStatement',
                ...
                argment: [Node]
            }
        },
        listKey: 'body',
        key: 0,
        node: Node {...},
        scope: Scope {...},
        type: 'ReturnStatement'
    }
*/

上述代码遍历 ReturnStatement 节点,直接输出 Path 对象。先回顾一下 1.1 小节介绍的AST结构,ReturnStatement 是放在 BlockStatement 的 body 节点中的,并且该 body节点是数组。接着来看一下Path对象中的关键属性,container 就是容器,在这里例子中它是一个数组,里面只有一个ReturnStatement 节点,与原始代码吻合。

listkey就是容器名 。ReturnStatement 是放在 ReturnStatement 的 body 节点中的,因此把 body 节点就当作是容器了。

接下来介绍一下 key。在上述代码输出的 Path 对象中,可以看到有一个 key 属性,这个 key 就是之前介绍的 path.get 方法的参数。实际上它就是容器对象的属性名。

这里的容器是一个数组,key 就代表当前节点在容器中的位置。在JS 中,数组就是一个特殊的对象。

var arr = {0:1000, 1:2000,2:3000};
console.log( arr[0] );
console.log( arr[1] );
console.log( arr[2] );
/*
1000
2000
3000
*/

斌给只有body节点才是容器,再来看一个例子:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
        encoding: "utf-8"
    });
let ast = parser.parse(jscode);

traverse(ast, {
    ObjectProperty(path) {
        console.log(path);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err) => {});
/*
    NodePath {
        parent: Node {...},
        ...
        parentPath: NodePath {...},
        ...
        container: [
            Node {
                type: 'ObjectProperty',
                start: 14,
                end: 34,
                loc: [Sourcelocation],
                method: false,
                key: [Node],
                computed: false,
                shorthand: false,
                value: [Node]
            },
            Node{...},
            Node{...},
        ],
        listKey: 'properties',
        key: 0,
        node: Node {...},
        scope: Scope {...},
        type: 'ObjectProperty'
    }
*/

上述代码遍历 ObjectProperty 节点,直接输出 Path 对象。

ObjectProperty 是在ObjectExpression 的propertys 属性中的。再来看 Path 对象中的关键属性,container 就是容器,listKey就是容器名。在原始代码中,有三个ObjectProperty,对应容器中的三个Node 对象。

key 为0表示当前节点是容器中下标为0的成员。也就是说容器中的节点互为兄弟(同级)节点。

container 并非一直都是数组,来看最后一个例子:

traverse(ast, {
    ObjectExpression(path){
        console.log(path);
    }
});
/*
    NodePath {
        parent: Node {...},
        ...
        parentPath: NodePath {...},
        ...
        container: Node {
            type: 'VariableDeclaratort',
            ...
            id: Node {
                type: 'Identifier',
                ...
                name: 'obj'
            },
            init: Node {
                type: 'ObjectExpression',
                ...
                properties: [Array],
                extra: [Object]
            }
        },
        listKey:undefined,
        key:'init',
        node: Node {...},
        scope: Scope {...},
        type: 'ObjectExression'
    }
*/

在上述代码中,container是一个Node对象,listKey为undefined,其实可以说它没有容器,也就是没有兄弟(同级)节点。在原始代码解析后的AST结构中,ObjectExpression是VariableDeclaration的初始化值(init节点)。此时,key不是数组下标,而是对象的属性名。

在说明白了容器以后,来介绍一下同级 Path 相关的属性和方法。一般 container 为数组的时候就有同级节点,以下内容只考虑 container 为数组的情况。因为只有这种情况才有意义。示例代码如下:

traverse(ast, {
    ReturnStatement(path){
        console.log(path.inList); // true
        console.log(path.container); //[node {type: 'ReturnStatement' ... }]
        console.log(path.listKey);  // body
        console.log(path.key);
        console.log(path.getsibling(path.key));
        // node{type:'ReturnStatement' ...}
    }
});
  1. pathinList
    用于判断是否有同级节点。注意,当 container 为数组,但是只有一个成员时,也会返回 true

  2. path. containerpath. listKeypath. key

    • 使用 path. key 获取当前节点在容器中的索引。
    • 使用 path.container 获取容器 (包含所有同级节点的数组)。
    • 使用 path.listKey 获取容器名。
  3. path.getSibling(index)

    用于获取同级 Path,其中参数 index 即容器数组中的索引。index 可以通过 path.key来获取。

    可以对 path.key 进行加减操作,来定位到不同的同级 Path。

  4. unshiftContainer 与 pushContainer

    const fs = require('fs');
    const parser = require("@babel/parser");
    const traverse = require("@babel/traverse").default;
    const t = require("@babel/types");
    const generator = require("@babel/generator").default;
    
    const jscode = fs.readFileSync("./demo.js", {
           encoding: "utf-8"
       });
    let ast = parser.parse(jscode);
    
    traverse(ast, {
       ReturnStatement(path) {
           path.parentPath.unshiftContainer('body', [
            t.expressionStatement(t.stringLiteral('Before1')),
               t.expressionStatement(t.stringLiteral('Before2'))]);
           console.log(path.parentPath.pushContainer('body', 
            t.expressionStatement(t.stringLiteral('After'))));
       }
    });
    
    let code = generator(ast).code;
    fs.writeFile('./demoNew.js', code, (err) => {});
    /*
         name: 'xiaojianbang',
         add: function (a, b) {
           "Before1";
           "Before2";
           return a + b + 1000;
           "After"; 
         },
         mul: function (a, b) {
           "Before1";
           "Before2";
           return a * b + 1000;
           "After";
         }
       };
    */

    从上述代码中可以看出unshiftContainer 用来往容器最前面加入节点,pushContainer用来往容器最后面加入节点。

    来看看它们在 ts 文件中的定义。

    pushContainer(listKey: ArrayKeys, nodes: Nodes): NodePaths;
    unshiftContainer(listKey: ArrayKeys, nodes:Nodes) : NodePaths;

    第一个参数很好理解给 listKey,第二个参数给 Nodes,Nodes 又是什么呢?

    Nodesextends Node | Node[]

    因此可以给 Node 或者 Node 的数组。

    最后函数返回加入的 Nodes的 Path 对象。

四、scope详解

scope 提供了一些属性和方法,可以方便的查找标识符的作用域,获取标识符的所有引用,修改标识符的所有引用,以及知道标识符是否参数,标识符是否常量,如果不是,那么在哪里修改了它。本小节以下面的代码为例:

const a = 1000;
let b = 2000;
let obj = {
    name: "xiaojianbang",
    add: function (a) {
        a = 400;
        b = 300;
        let e = 700;
        function demo () {
            let d = 600;
        }
        demo();
        return a + a + b + 1000 + obj.name;
    }
};
obj.add(100);

为了精简代码,上述代码只留了一个 add 函数,但是在 add 函数中定义了一个 demo 函数,以这种方式定义的函数,在 AST 中类型为 FunctionDeclaration

1.获取标识符作用域

scope.block 属性可以用来获取标识符作用域,返回的是 Node 对象。使用方法分为两种情况,变量和函数。

先来看标识符为变量的情况:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    Identifier(path) {
        if (path.node.name == 'e') {
            console.log(generator(path.scope.block).code);
        }
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    function (a) {
      a = 400;
      b = 300;
      let e = 700;
      function demo() {
        let d = 600;
      }
      demo();
      return a + a + b + 1000 + obj.name;
    }
*/

既然 path.scope.block 返回的是 Node 对象,那么就可以使用 generator 来生成代码。

上述代码遍历所有 Identifier,当名字为e 的时候,把当前节点的作用域转代码。变量e是定义在 add 函数内部的,作用域范围就是整个add 函数。这个很好理解,但是如果遍历的是一个函数的话,它的作用域有点特别。来看下面这个例子:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) {
        console.log(generator(path.scope.block).code);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    function demo() {
      let d = 600;
    }
*/

上述代码遍历 FunctionDeclaration,在原代码中只有 demo 函数符合要求,但是 demo函数的作用域实际上应该是整个 add 函数的范围。因此输出的与实际的不符,这时候需要去获取父级作用域。获取函数的作用域代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) {
        console.log(generator(path.scope.parent.block).code);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    function (a) {
      a = 400;
      b = 300;
      let e = 700;
      function demo() {
        let d = 600;
      }
      demo();
      return a + a + b + 1000 + obj.name;
    }
*/

2.获取标识符的绑定scope.getBinding

scope.getBinding 方法接收一个类型为 string 参数,用来获取对应标识符的绑定。绑定又是什么东西呢?待笔者慢慢道来,来看下面这段代码,遍历 FunctionDeclaration,符合要求的就只有 demo 函数,然后获取当前节点下的绑定a,直接输出 binding,代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) {
        let binding = path.scope.getBinding('a');
        console.log(binding);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    Binding {
        identifier: Node {type: 'Identifier', ...name: 'a'},
        scope: Scope {...
            block: Node {type: 'FunctionExpression', ...} ... },
        path: NodePath {...},
        kind: 'param',
        constantViolations: [...]
        constant: false,
        referencePaths: [...],
        referenced: true,
        references: 2,...}
*/

注意,getBinding 中传的值必须是当前节点能够引用到的标识符名。比如你传入的是 g,这个标识符并不存在,或者说当前节点引用不到,那么 getBinding 方法会返回undefined。

接着来看下 Binding 中几个关键的属性,

  • identifier就是a 标识符的 Node 对象,path 就是 a标识符的 Path 对象。
  • kind 中表明了这是一个参数,但是注意它并不代表就是当前 demo函数的参数。实际上在原代码中,a 是 add 函数的参数(当函数中局部变量与全局变量重名的时候,使用的是局部变量)。
  • referenced 表示当前标识符是否被引用。
  • references 表示当前标识符被引用的次数。这里只读取的操作,赋值操作不算被引用。a + a + b + 1000 + obj.name;
  • constant 表示是否常量。
  • referencePaths 与constantViolations后续内容单独描述。

另外可以看出 Binding 中也有 scope。因为获取的是 a 的 Binding,所以是 a的scope将其中的 block 节点转为代码后,可以看出它的作用域范围就是 add 函数。但是有意思的是假如获取的是 demo 的 Binding,将其中的 block 节点转为代码后,输出的也是 add 函数。因此,获取函数作用域也可以用如下这种方式:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionExpression(path) {
        let bindingA = path.scope.getBinding('a');
        let bindingDemo = path.scope.getBinding('demo');
        console.log(bindingA.referenced);
        console.log(bindingA.references);
        console.log(generator(bindingA.scope.block).code);
        console.log(generator(bindingDemo.scope.block).code);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    true
    2
    function (a) {
      a = 400;
      b = 300;
      let e = 700;
      function demo() {
        let d = 600;
      }
      demo();
      return a + a + b + 1000 + obj.name;
    }
    function (a) {
      a = 400;
      b = 300;
      let e = 700;
      function demo() {
        let d = 600;
      }
      demo();
      return a + a + b + 1000 + obj.name;
    }
*/

3.scope.getOwnBinding()

该函数用于获取当前节点自己的绑定,也就是不包含父级作用域中定义的标识符的绑定。但是该函数会得到子函数中定义的标识符的绑定.

来看一个例子:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

function TestOwnBinding(path){
    path.traverse({
        Identifier(p) {
            let name = p.node.name;
            console.log( name, !!p.scope.getOwnBinding(name) );
        }
    });
}
traverse(ast, {
    FunctionExpression(path){
        TestOwnBinding(path);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    a true
    a true
    b false
    e true
    demo false
    d true
    demo true
    a true
    a true
    a true
    b false
    obj false
    name false
*/

上述代码遍历 FunctionExpression 节点,当前案例中符合要求的只有 add 函数,然后遍历该函数下所有 Identifier,输出标识符名和getOwnBinding 的结果。查看输出结果,可以发现子函数 demo 中的定义的 d 变量,也可以通过 getOwnBinding 得到。

也就是说,如果只想获取当前节点下定义的标识符,而不涉及子函数的话,还需要进一步判断。笔者在这里使用判断标识符作用域是否与当前函数一致来确定。

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

function TestOwnBinding(path){
    path.traverse({
        Identifier(p) {
            let name = p.node.name;
            let binding = p.scope.getBinding(name);
            binding && console.log( name, generator(binding.scope.block).code == path + '' );
        }
    });
}
traverse(ast, {
    FunctionExpression(path){
        TestOwnBinding(path);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
a true
a true
b false
e true
demo true
d false
demo true
a true
a true
a true
b false
obj false
*/

4.referencePaths与constantViolations

首先介绍 referencePaths。假如标识符被引用,referencePaths 中会存放所有引用该识符的节点的 Path 对象。查看 referencePaths 中的内容,如下所示:

referencePaths:[
    NodePath {
        parent: Node {type:'BinaryExpression',...},...
        parentPath: NodePath { ...type: 'BinaryExpression'},...
        container: Node{type: 'BinaryExpression', ...},
        listKey: undefined,
        key: 'left',
        node: Node { type:'Identifier',... name: 'a'},
        scope: Scope { ... },
        type: 'Identifier'
     },
    NodePath { ... key: 'right',... type: 'Identifier')]

可以看出,referencePaths 是一个数组。在原始代码中使用的是a+a,所以有两处用,对应这里的两个成员。

其中 Node 对象是引用处的 a 标识符本身。因为这是一个二项式,所以两处引用分别是 BinaryExpression 的 left 和 right,它们的父节点自然是二项式。

没有兄弟节点,所以 container 是一个对象,listKey 是 undefined。

再看介绍constantViolations。假如标识符被修改,constantViolations中会存放所有修改该标识符节点的 Path 对象。查看 constantViolations 中的内容,原始代码中有一处修改了a变量,它是一个赋值表达式,left 是 a,right 是 400,如下所示:

constantViolations: [
    NodePath{...
    node: Node [
        type:'AssignmentExpression',...
        operator:'=',
        left: Node{ type:'Identifier',... name: 'a'),
        right: Node{ type: 'NumericLiteral', ... value: 400 }}, ...}]
    }
]

5.遍历作用域

scope.traverse 方法可以用来遍历作用域中的节点。可以使用 Path对象中的 scope,也可以使用 Binding 中的 scope,推荐使用后者。来看下面这个例子:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) {
        let binding = path.scope.getBinding('a');
        binding.scope.traverse(binding.scope.block, {
            AssignmentExpression(p) {
                if (p.node.left.name == 'a')
                    p.node.right = t.numericLiteral(500);
            }
        });
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    const a = 1000;
    let b = 2000;
    let obj = {
      name: "xiaojianbang",
      add: function (a) {
        a = 500;
        b = 300;
        let e = 700;
        function demo() {
          let d = 600;
        }
        demo();
        return a + a + a + b + 1000 + obj.name;
      }
    };
    obj.add(100);
*/

原始代码中,a=400,上述代码的作用是将它改为 a=500。

假如是从 demo这个函数入手,那么只要获取 demo函数中的a的 Binding, 然后遍历 binding.scope.block(也就是a的作用域),找到赋值表达式是 left 为 a 的,将对应的 right 改掉。

6.标识符重命名

可以使用 scope.rename 将标识行进行重命名,这个方法会同时修改所有引用该标识符的地方。

例如将add函数中的b变量重命名为x,代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) {
        let binding = path.scope.getBinding('b');
        binding.scope.rename('b', 'x');
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    const a = 1000;
    let x = 2000;
    let obj = {
      name: "xiaojianbang",
      add: function (a) {
        a = 400;
        x = 300;
        let e = 700;
        function demo() {
          let d = 600;
        }
        demo();
        return a + a + a + x + 1000 + obj.name;
      }
    };
    obj.add(100);
*/

上述方法很方便,但是如果指定一个名字,可能会与现有标识符冲突。这时可以使scope.generateUidIdentifier 来生成一个标识符,生成的标符不会与任何本地定义的标符相冲突,如:

traverse(ast, {
    FunctionExpression(path){
        path.scope.generateUidIdentifier("uid");
        // Node ( type: "Identifier", name: "_uid")
        path.scope.generateUidIdentifier("uid");
        // Node ( type:"Identifier",name: uid2"
)};

使用这两种方法,就可以实现一个简单的标识符混淆方案。代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast,{
    Identifier(path){
        path.scope.rename(path.node.name,
                           path.scope.generateUidIdentifier( '0x2ba6ea').name);
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
const _x2ba6ea = 1000;
let _x2ba6ea16 = 2000;
let _x2ba6ea19 = {
  name: "xiaojianbang",
  add: function (_x2ba6ea15) {
    _x2ba6ea15 = 400;
    _x2ba6ea16 = 300;
    let _x2ba6ea9 = 700;
    function _x2ba6ea12() {
      let _x2ba6ea11 = 600;
    }
    _x2ba6ea12();
    return _x2ba6ea15 + _x2ba6ea15 + _x2ba6ea15 + _x2ba6ea16 + 1000 + _x2ba6ea19.name;
  }
};
_x2ba6ea19.add(100);
*/

实际上标识符混淆方案还可以做得更复杂。例如,上述代码中,如果再多定义一些函数,会发现各函数之间的局部变量名是不重复的。假如把各个函数之间的局部变量定义成重复的,甚至还可以让函数中的局部变量跟当前函数中没有引用到的全局变量重名,原始代码中的全局变量 a与 add 中的 a参数。更复杂的标识符混淆方案将在第9章详细介绍。

7.scope的其他方法

1.scope.hasBinding('a')

该方法查询是否有标识符a的绑定,返回 true 或 false。可以用 scope.getBinding('a')代替,scope. getBinding('a')返回 ndefined,等同于 scope. hasBinding('a')返回false.

2.scope.hasOwnBinding('a')

该方法查询当前节点中是否有自己的绑定,返回 true 或false。例如,对于 demo函数OwnBinding只有一个 d。函数名 demo虽然也是标识符,但不属于demo函数的OwnBinding 范畴,是属于它的父级作用域的,如:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) { 
        console.log( path.scope.parent.hasOwnBinding('demo') );
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
//true

同样可以使用 scope.getOwnBinding('a')代替它,scope. getOwnBinding('a')返undefined,等同于 scope. hasOwnBinding ('a')返回 false。原始代码中 scopehasOwnBinding('a')也是通过 scopegetOwnBinding('a')来实现的。

3.scope. getAllBindings

该方法获取当前节点的所有绑定,会返回一个对象。该对象以标识符名为属性名,对应的 Binding为属性值,代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const t = require("@babel/types");
const generator = require("@babel/generator").default;

const jscode = fs.readFileSync("./demo.js", {
    encoding: "utf-8"
  });
let ast = parser.parse(jscode);

traverse(ast, {
    FunctionDeclaration(path) { 
        console.log( path.scope.getAllBindings() );
    }
});

let code = generator(ast).code;
fs.writeFile('./demoNew.js', code, (err)=>{});
/*
    [Object: null prototype]{
        d:Binding {...},
        a: Binding {...},
        demo: Binding {...},
        e: Binding {...},
        b:Binding {...},
        obj: Binding {...}
    }
*/

4.scope. hasReference('a')

该方法查询当前节点中是否有 a标识符的引用,返回true或 false

5.scope. getBindingldentifier( 'a')

该方法获取当前节点中绑定的a标识符,返回Identifier 的Node对象。

同样,这个方法也有Own版本,为scope.getOwnBindingIdentifier('a')。

五、小结

本章详细介绍了AST的基本结构,了解AST 结构是学习后续知识的基础。

接着讲述了traverse 组件与 visitor 的使用,这在 Babel自动化处理JS代码过程中必不可少。

然后阐明了 types 组件的用法,如果要生成新的节点,这个组件最合适。如果要修改节点或者将生成的新节点加人到已有的节点中,那么 Path 对象相关的方法必不可少。

Path 对象是本章的重点。另外,标识符都具有自己的作用域,不同作用域中可能会有相同名字的标识符,因此在处理代码的过程中,要格外小心这种情况,选择在作用域内遍历节点更合理,Babel中的 scope 是处理这种情况的“利器”。

Babel 中的API还有很多没有介绍,剩余的需要自己去学习。一定要多看多练和多思考,才能更快地掌握这些 API的使用。

发表回复

您的电子邮箱地址不会被公开。