操作JavaScript的AST js使用axios

文/ 阿里 淘系 F(x)Team - 旭伦

前面我们学习了eslint和stylelint的规则的写法,当大家实际去写的时候,一定会遇到很多细节的问题,比如解析的代码是有错误的,或者是属性值不足以分析出问题来之类的。我们还需要更多的工具来帮助我们简化规则开发的流程。比如说容错度更高的解析器,或者获取更丰富属性的工具。

我们知道,Eslint主要是基于AST层次进行操作的。

我们知道,eslint支持更换解析器,那么,它就需要一套标准。eslint使用的这套标准叫做estree规范。estree规范的指导委员会的三名成员,恰好来自eslint, acorn和babel.

Estree的基础格式,可以从ES5规范中查看到,从ES6规范开始,每个版本都增加新的规范。比如ES2016增加对"**"运算符的支持。

acorn解析器是支持plugin机制的,于是eslint所用的espree解析器和babel的解析器就都在acorn上进行扩展。


Acorn解析器

acorn的默认用法非常简单,直接来段代码字符串parse一下就出来AST结构了:


let acorn = require("acorn");

console.log(acorn.parse("for(let i=0;i<10;i+=1){console.log(i);}", {ecmaVersion: 2020}));


输出如下:

Node {
  type: 'Program',
  start: 0,
  end: 39,
  body: [
    Node {
      type: 'ForStatement',
      start: 0,
      end: 39,
      init: [Node],
      test: [Node],
      update: [Node],
      body: [Node]
    }
  ],
  sourceType: 'script'
}


遍历语法树

解析好了语法树节点之后,我们就可以遍历树了。acorn-walk包为我们提供了遍历的能力。

Acorn-walk提供了几种粒度的遍历方式,比如我们用simple函数遍历所有的Literal值:

const acorn = require("acorn")
const walk = require("acorn-walk")

const code = 'for(let i=0;i<10;i+=1){console.log(i);}';

walk.simple(acorn.parse(code, {ecmaVersion:2020}), {
    Literal(node) {
        console.log(`Found a literal: ${node.value}`);
    }
});


输出如下:

Found a literal: 0
Found a literal: 10
Found a literal: 1


当然,更经常使用的是full函数:

const acorn = require("acorn")
const walk = require("acorn-walk")

const code = 'for(let i=0;i<10;i+=1){console.log(i);}';
const ast1 = acorn.parse(code, {ecmaVersion:2020});

walk.full(ast1, function(node){
    console.log(node.type);
});


输出如下:

Identifier
Literal
VariableDeclarator
VariableDeclaration
Identifier
Literal
BinaryExpression
Identifier
Literal
AssignmentExpression
Identifier
MemberExpression
Identifier
CallExpression
ExpressionStatement
BlockStatement
ForStatement
Program


我们可以看到,最后是树根Program.


高容错版本 acorn-loose

Acorn正常使用起来没有什么问题,但是还有一点可以做得更好,就是容错的情况。

我们看一个出错的例子:

let acorn = require("acorn");

console.log(acorn.parse("let a = 1 );", {ecmaVersion: 2020}));


Acorn就不干了,报错:

SyntaxError: Unexpected token (1:10)
    at Parser.pp$4.raise (acorn/node_modules/acorn/dist/acorn.js:3434:15)
    at Parser.pp$9.unexpected (acorn/node_modules/acorn/dist/acorn.js:749:10)
    at Parser.pp$9.semicolon (acorn/node_modules/acorn/dist/acorn.js:726:68)
    at Parser.pp$8.parseVarStatement (acorn/node_modules/acorn/dist/acorn.js:1157:10)
    at Parser.pp$8.parseStatement (acorn/node_modules/acorn/dist/acorn.js:904:19)
    at Parser.pp$8.parseTopLevel (acorn/node_modules/acorn/dist/acorn.js:806:23)
    at Parser.parse (acorn/node_modules/acorn/dist/acorn.js:579:17)
    at Function.parse (acorn/node_modules/acorn/dist/acorn.js:629:37)
    at Object.parse (acorn/node_modules/acorn/dist/acorn.js:5546:19)
    at Object.<anonymous> (acorn/normal.js:3:19) {
  pos: 10,
  loc: Position { line: 1, column: 10 },
  raisedAt: 11
}


下面我们换成高容错版本的acorn-loose:

let acornLoose = require("acorn-loose");

console.log(acornLoose.parse("let a = 1 );", { ecmaVersion: 2020 }));


Acorn-loose会将我们多写的半个括号识别成一个空语句:

Node {
  type: 'Program',
  start: 0,
  end: 12,
  body: [
    Node {
      type: 'VariableDeclaration',
      start: 0,
      end: 9,
      kind: 'let',
      declarations: [Array]
    },
    Node { type: 'EmptyStatement', start: 11, end: 12 }
  ],
  sourceType: 'script'
}


Espree解析器

espree既然是扩展acorn,基本用法当然是兼容的:

const espree = require("espree");

const code = "for(let i=0;i<10;i+=1){console.log(i);}";

const ast = espree.parse(code,{ ecmaVersion: 2020 });

console.log(ast);


生成的格式当然也是estree, 跟acorn一样:

Node {
  type: 'Program',
  start: 0,
  end: 39,
  body: [
    Node {
      type: 'ForStatement',
      start: 0,
      end: 39,
      init: [Node],
      test: [Node],
      update: [Node],
      body: [Node]
    }
  ],
  sourceType: 'script'
}


如果看AST还不行,我们还可以直接看分词的效果:

const tokens = espree.tokenize(code,{ ecmaVersion: 2020 });
console.log(tokens);


结果如下:

[
  Token { type: 'Keyword', value: 'for', start: 0, end: 3 },
  Token { type: 'Punctuator', value: '(', start: 3, end: 4 },
  Token { type: 'Keyword', value: 'let', start: 4, end: 7 },
  Token { type: 'Identifier', value: 'i', start: 8, end: 9 },
  Token { type: 'Punctuator', value: '=', start: 9, end: 10 },
  Token { type: 'Numeric', value: '0', start: 10, end: 11 },
  Token { type: 'Punctuator', value: ';', start: 11, end: 12 },
  Token { type: 'Identifier', value: 'i', start: 12, end: 13 },
  Token { type: 'Punctuator', value: '<', start: 13, end: 14 },
  Token { type: 'Numeric', value: '10', start: 14, end: 16 },
  Token { type: 'Punctuator', value: ';', start: 16, end: 17 },
  Token { type: 'Identifier', value: 'i', start: 17, end: 18 },
  Token { type: 'Punctuator', value: '+=', start: 18, end: 20 },
  Token { type: 'Numeric', value: '1', start: 20, end: 21 },
  Token { type: 'Punctuator', value: ')', start: 21, end: 22 },
  Token { type: 'Punctuator', value: '{', start: 22, end: 23 },
  Token { type: 'Identifier', value: 'console', start: 23, end: 30 },
  Token { type: 'Punctuator', value: '.', start: 30, end: 31 },
  Token { type: 'Identifier', value: 'log', start: 31, end: 34 },
  Token { type: 'Punctuator', value: '(', start: 34, end: 35 },
  Token { type: 'Identifier', value: 'i', start: 35, end: 36 },
  Token { type: 'Punctuator', value: ')', start: 36, end: 37 },
  Token { type: 'Punctuator', value: ';', start: 37, end: 38 },
  Token { type: 'Punctuator', value: '}', start: 38, end: 39 }
]


从结果可以看到,词法分析之后的结果是token,而语法分析之后的已经是语句的结果了。

关于为什么eslint为什么在acorn之上还要封装一个espree,是因为最早eslint依赖于esprima,二者之间有不兼容的地方,eslint需要更多的信息来分析代码。


Babel基础操作

最后出场的是虽然不是eslint默认,但是双向支持都不错的重型武器babel.


Babel解析器

大杀器babel也可以配置成只要ast的模式:


const code2 = 'function greet(input) {return input ?? "Hello world";}';

const babel = require("@babel/core");
result = babel.transformSync(code2, { ast: true });

console.log(result.ast);


输出结果是这样的:

Node {
  type: 'File',
  start: 0,
  end: 54,
  loc: SourceLocation {
    start: Position { line: 1, column: 0 },
    end: Position { line: 1, column: 54 },
    filename: undefined,
    identifierName: undefined
  },
  errors: [],
  program: Node {
    type: 'Program',
    start: 0,
    end: 54,
    loc: SourceLocation {
      start: [Position],
      end: [Position],
      filename: undefined,
      identifierName: undefined
    },
    sourceType: 'module',
    interpreter: null,
    body: [ [Node] ],
    directives: [],
    leadingComments: undefined,
    innerComments: undefined,
    trailingComments: undefined
  },
  comments: [],
  leadingComments: undefined,
  innerComments: undefined,
  trailingComments: undefined
}


我们还可以用babel.parseSync方法只去读取AST:

result2 = babel.parseSync(code2);
console.log(result2);


甚至我们可以只用parser包:

const babelParser = require('@babel/parser');
console.log(babelParser.parse(code2, {}));


Babel的遍历器

Acorn有专门的遍历器包,Babel当然也不甘示弱,提供了@babel/traverse包来辅助遍历抽象语法树。


我们来看个代码节点路径的例子:

const code4 = 'let a = 2 ** 8;'
const ast4 = babelParser.parse(code4, {})
const traverse2 = require("@babel/traverse");
traverse2.default(ast4, {
    enter(path) {
        console.log(path.type);
    }
});


输出如下,是从Program自顶向下的路径:

Program
VariableDeclaration
VariableDeclarator
Identifier
BinaryExpression
NumericLiteral
NumericLiteral


类型判断

遍历之后,我们需要大量的工具函数去进行类型判断。Babel给我们提供了一个巨大的工具类库@babel/types.


比如,我们想判断一个AST节点是不是标识符,就可以调用isIdentifier函数去判断下,我们看个例子

const code6 = 'if (a==2) {a+=1};';
const t = require('@babel/types');
const ast6 = babelParser.parse(code6, {})
traverse2.default(ast6, {
    enter(path) {
        if (t.isIdentifier(path.node)) {
            console.log(path.node);
        }
    }
});


输出如下:

Node {
  type: 'Identifier',
  start: 4,
  end: 5,
  loc: SourceLocation {
    start: Position { line: 1, column: 4 },
    end: Position { line: 1, column: 5 },
    filename: undefined,
    identifierName: 'a'
  },
  name: 'a'
}
Node {
  type: 'Identifier',
  start: 11,
  end: 12,
  loc: SourceLocation {
    start: Position { line: 1, column: 11 },
    end: Position { line: 1, column: 12 },
    filename: undefined,
    identifierName: 'a'
  },
  name: 'a'
}


现在,我们要判断有没有表达式使用了"=="运算符,就可以这样写:

const code8 = 'if (a==2) {a+=1};';
const ast8 = babelParser.parse(code6, {})
traverse2.default(ast8, {
    enter(path) {
        if (t.isBinaryExpression(path.node)) {
            if(path.node.operator==="=="){
                console.log(path.node);
            }
        }
    }
});


isBinaryExpression也支持参数,我们可以把运算符的条件加上:

traverse2.default(ast8, {
    enter(path) {
        if (t.isBinaryExpression(path.node,{operator:"=="})) {
            console.log(path.node);
        }
    }
});


构造AST节点

光能判断类型还不算什么。@babel/type库的更主要的作用是可以用来生成AST Node。

比如我们要生成一个二元表达式,就可以用binaryExpression函数来生成:

let node7 = t.binaryExpression("==",t.identifier("a"),t.numericLiteral(0));
console.log(node7);


注意,标识符和字面量都不能生接给值,而是要用自己类型的构造函数来生成哈。

运行结果如下:

{
  type: 'BinaryExpression',
  operator: '==',
  left: { type: 'Identifier', name: 'a' },
  right: { type: 'NumericLiteral', value: 0 }
}


要把运算符""改成"=",直接替换掉就好:

node7.operator="===";
console.log(node7);


输出结果如下:

{
  type: 'BinaryExpression',
  operator: '===',
  left: { type: 'Identifier', name: 'a' },
  right: { type: 'NumericLiteral', value: 0 }
}


我们把上面的逻辑串一下,将""运算符替换成"="运算符的代码如下:

const code8 = 'if (a==2) {a+=1};';
const ast8 = babelParser.parse(code6, {})
traverse2.default(ast8, {
    enter(path) {
        if (t.isBinaryExpression(path.node,{operator:"=="})) {
            path.node.operator = "===";
        }
    }
});


AST生成代码

下面我们的高光时刻来了,直接生成代码。babel为我们准备了"@babel/generator"包:

const generate = require("@babel/generator") ;
let c2 = generate.default(ast8,{});
console.log(c2.code);


生成的代码如下:

if (a === 2) {
  a += 1;
}

;


代码模板

我们要生成的代码都通过AST表达式来写有时候有点反人性,这时候我们可以尝试下代码模板。

我们来看个例子:

const babelTemplate = require("@babel/template");
const requireTemplate = babelTemplate.default(`
  const IMPORT_NAME = require(SOURCE);
`);

const ast9 = requireTemplate({
    IMPORT_NAME: t.identifier("babelTemplate"),
    SOURCE: t.stringLiteral("@babel/template")
});

console.log(ast9);


请注意,通过代码模板生成的直接就是AST哈,做的可不是模板字符串替换,替换的可是标识符和文本字面常量。

输出结果如下:

{
  type: 'VariableDeclaration',
  kind: 'const',
  declarations: [
    {
      type: 'VariableDeclarator',
      id: [Object],
      init: [Object],
      loc: undefined
    }
  ],
  loc: undefined
}


想要转成源代码,还需要调用generate包:

console.log(generate.default(ast9).code);


输出如下:

const babelTemplate = require("@babel/template");


另外,需要注意的是,我们的代码模板生成的是抽象语法树,不是具体语法树,比如我们在代码模板里写了注释,最后生成回代码里可就没有了:

const forTemplate = babelTemplate.default(`
    for(let i=0;i<END;i+=1){
        console.log(i); // output loop variable
    }
`);
const ast10 = forTemplate({
    END: t.numericLiteral(10)
});
console.log(generate.default(ast10).code);


生成的代码如下:

for (let i = 0; i < 10; i += 1) {
  console.log(i);
}


Babel高级操作

Babel转码器

既然有了babel,我们只用其parser有点浪费了,我们可以在我们的代码中使用babel来作为转码器:

const code2 = 'function greet(input) {return input ?? "Hello world";}';
const babel = require("@babel/core");
let result = babel.transformSync(code2, {
    targets: ">0.5%",
    presets: ["@babel/preset-env"]});

console.log(result.code);


记得安装@babel/core和@babel/preset-env。

结果如下:

"use strict";

function greet(input) {
  return input !== null && input !== void 0 ? input : "Hello world";
}


我们再来个ES 6 Class转换的例子:

const code3 = `
//Test Class Function
class Test {
    constructor() {
      this.x = 2;
    }
  }`;

const babel = require("@babel/core");
let result = babel.transformSync(code3, {
    presets: ["@babel/preset-env"]
});

console.log(result.code);


除了presets: ["@babel/preset-env"]需要指定外,其它用缺省的参数就好。

生成的代码如下:

"use strict";

function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }

//Test Class Function
var Test = function Test() {
  _classCallCheck(this, Test);

  this.x = 2;
};


在eslint规则中,如果源代码没有转码器,我们就可以利用babel直接转码生成autofix.


AST节点的替换

前面我们只修改了二元表达式中的运算符,不过这样的情况在实际中很少见。实际情况中我们经常要修改一大段表达式。这时候我们可以用replaceWith函数将旧的AST节点换成新的AST节点。

还以将""换成"="为例,这次我们改成直接生成一个新的binaryExpression来替换原有的,表达式中的左右节点都不变:

const babel = require("@babel/core");
const babelParser = require('@babel/parser');
const t = require('@babel/types');
const traverse = require("@babel/traverse");
const generate = require("@babel/generator");

const code8 = 'if (a==2) {a+=1}; if (a!=0) {a=0}';
const ast8 = babelParser.parse(code8, {})
traverse.default(ast8, {
    enter(path) {
        if (t.isBinaryExpression(path.node, {operator: "=="})) {
            path.replaceWith(t.binaryExpression("===", path.node.left, path.node.right));
        }else if(t.isBinaryExpression(path.node, {operator: "!="})){
            path.replaceWith(t.binaryExpression("!==", path.node.left, path.node.right));
        }
    }
});

let c2 = generate.default(ast8, {});
console.log(c2.code);


输出结果如下:

if (a === 2) {
  a += 1;
}

;

if (a !== 0) {
  a = 0;
}


AST节点的删除

我们在review代码的时候,经常有发现console.log语句没有被删除之类的问题。此时我们就可以写一个AST处理工具,将console.log语句删除。直接调用节点的remove方法就可以删除掉当前节点。

console.log是一个函数调用,它是一个CallExpression,调用者是CallExpression的callee属性:

let code11 = "let a = 1; console.log(a);"
const ast11 = babelParser.parse(code11, {})
traverse.default(ast11, {
    enter(path) {
        if (t.isCallExpression(path) && t.isMemberExpression(path.node.callee)) {
            if (path.node.callee.object.name === "console" && path.node.callee.property.name === "log") {
                path.remove();
            }
        }
    }
});
const c11 = generate.default(ast11, {});
console.log(c11.code);


输出如下:

let a = 1;


或者干脆更进一步,只要是console对象,管它调用的是什么函数,统统都删掉:

let code12 = "let a = 1; console.log(a); console.info('Hello,World!')";
const ast12 = babelParser.parse(code12, {})
traverse.default(ast12, {
    enter(path) {
        if (t.isCallExpression(path) && t.isMemberExpression(path.node.callee)) {
            if (path.node.callee.object.name === "console") {
                path.remove();
            }
        }
    }
});
const c12 = generate.default(ast11, {});
console.log(c12.code);


作用域

Babel同样支持作用域的分析。

比如,我们可以用scope.hasBinding检查在这个scope中某局部变量是否有被绑定,也可以用scope.hasGlobal来检查是否定义了某全局变量。

如果本作用域有绑定变量,可以通过getBinding函数来获取其初始值。

我们来看个例子:

let code13 = `
g = 1;
function test(){
    let a = 0;
    for(let i = 0;i<10;i++){
        a+=i;
    }
}
`;
const ast13 = babelParser.parse(code13, {})
traverse.default(ast13, {
    enter(path) {
        console.log(path.type);
        const is_a = path.scope.hasBinding('a');
        console.log(is_a);
        if(is_a){
            console.log(path.scope.getBinding('a').path.node.init.value);
        }
        console.log(path.scope.hasGlobal('g'));
    }
});


输出如下:

Program
false
true
ExpressionStatement
false
true
AssignmentExpression
false
true
Identifier
false
true
NumericLiteral
false
true
FunctionDeclaration
true
0
true
Identifier
true
0
true
BlockStatement
true
0
true
...


我们看到,到了函数声明FunctionDeclaration开始,函数内定义的变量a开始被绑定,我们能够获取到其初始值0.


用Babel高亮和标记出错代码

除了可以分析修改AST、AST生成代码和转码这些常规操作之外,Babel还提供了code-frame功能来标记代码,让出错信息的可读性更好。

我们来看个例子:

const codeFrame = require("@babel/code-frame");
const rawLines2 = 'let a = isNaN(b);';
const result2 = codeFrame.codeFrameColumns(rawLines2, {
    start: {line: 1, column: 9},
    end: {line: 1, column: 14},
}, {highlightCode: true});

console.log(result2);


我们来看下结果:


有代码高亮,还有错误标红,是不是对用户很友好?

我们再看个跨行的例子,我们只要标记首尾信息就好,其余交给@babel/code-frame去解决:

const rawLines3 = ["class CodeAnalyzer {", "  constructor()", "};"].join("\n");
const result3 = codeFrame.codeFrameColumns(rawLines3, {
    start: {line: 2, column: 3},
    end: {line: 2, column: 16},
}, {highlightCode: true});

console.log(result3);


输出结果如下:


将isNaN替换为Number.isNaN的完整例子

上面的知识可能还有点零散,我们来个例子将它们串一下。
下面的js脚本从命令行参数读入一个js文件名,然后去查找它的isNaN的调用,主要是要把参数保存起来,接着将其替换为Number.isNaN的调用:


const babel = require("@babel/core");
const babelParser = require('@babel/parser');
const t = require('@babel/types');
const traverse = require("@babel/traverse");
const generate = require("@babel/generator");
const babelTemplate = require("@babel/template");
const fs = require("fs");

let args = process.argv;

if (args.length !== 3) {
    console.error('Please input a js file name:');
} else {
    let filename = args[2];
    let all_code = fs.readFileSync(filename, { encoding: 'utf8' });
    fix(all_code);
}

function fix(code) {
    const isNaNTemplate = babelTemplate.default(`Number.isNaN(ARG);`);
    const ast0 = babel.transformSync(code, { ast: true })?.ast;
    traverse.default(ast0, {
        enter(path) {
            if (t.isCallExpression(path) && path.node.callee.name === 'isNaN') {
                let arg1 = path.node.arguments;
                const node2 = isNaNTemplate({
                    ARG: arg1,
                });
                path.replaceWith(node2);
            }
        }
    });

    const c2 = generate.default(ast0, {});
    console.log(c2.code);
}


小结

通过本文学习这些工具,我们扩展了解析高容错代码的能力,修改和生成AST的能力,重新生成代码的能力等。AST级别的操作比起直接处理源代码和文本替换,不管是在安全性还是便利上都有经较显著的提升。

原文链接:,转发请注明来源!