美文网首页
初探AST-抽象语法树

初探AST-抽象语法树

作者: hellomyshadow | 来源:发表于2020-01-13 14:05 被阅读0次

    中文原文:https://segmentfault.com/a/1190000016231512

    Javascript就像一台精妙运作的机器,我们可以用它来完成一切天马行空的构思。
    我们对Javascript生态了如指掌,却常忽视Javascript本身,究竟是哪些零部件在支持着它运行?

    AST在日常业务中也许很难涉及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出vue、react之类的大型框架,或类似webpack、vue-cli前端的自动化工具,或者有批量修改源码的工程需求,那你必须懂得ASTAST的能力十分强大,且能帮你真正吃透Javascript的语言精髓。

    事实上,在Javascript世界中,你可以认为抽象语法树(AST)是最底层。 再往下,就是关于转换和编译的“黑魔法”领域了。

    拆解JavaScript

    拆解一个简单的add函数

        function add(a, b) {
            return a + b
        }
    
    • 首先,这个语法块是一个FunctionDeclaration(函数定义)对象。
      拆开成了三块
      • 一个id,也就是它的名字:add
      • 两个params,参数:[a, b]
      • 一块body,函数体

    至此,add就无法继续拆解了,它是一个最基础Identifier(标志)对象,用来作为函数的唯一标志。

        {
            name: 'add'
            type: 'identifier'
            ...
        }
    
    • params继续拆解成两个Identifier组成的数组,之后也没办法拆下去了。
        [
            {
                name: 'a'
                type: 'identifier'
                ...
            },
            {
                name: 'b'
                type: 'identifier'
                ...
            }
        ]
    
    • 拆解body
      body其实是一个BlockStatement(块状域)对象,用来表示是{ return a + b }
      打开Blockstatement,里面藏着一个ReturnStatement(Return域)对象,用来表示return a + b
      继续打开ReturnStatement,里面是一个BinaryExpression(二项式)对象,用来表示a + b
      继续打开BinaryExpression,它成了三部分:left,operator,right
      • operator:+
      • left 里面装的,是Identifier对象 a
      • right 里面装的,是Identifier对象 b

    拆解之后,用图表示:

    拆解图.png

    Look!抽象语法树(Abstract Syntax Tree,AST)的确是一种标准的树结构。
    至于 Identifier、Blockstatement、ReturnStatement、BinaryExpression 等部件的说明书,可查看
    AST对象文档

    AST螺丝刀:recast

    安装 npm i recast -S 即可获得一把操纵语法树的螺丝刀;
    新建一个文件parse.js

        const recast = require("recast");
        // 我们使用了很奇怪格式的代码,想测试是否能维持代码结构
        const code =
            `
            function add(a, b) {
                return a +
                  // 有什么奇怪的东西混进来了
                  b
            }
            `
        // 用螺丝刀解析机器
        const ast = recast.parse(code);
    
        // ast可以处理很巨大的代码文件
        // 但我们现在只需要代码块的第一个body,即add函数
        const add  = ast.program.body[0]
    
        console.log(add)
    

    运行 node parse.js 可以查看add函数的结构:

        FunctionDeclaration {
            type: 'FunctionDeclaration',
            id: ...
            params: ...
            body: ...
            ...
        }
    

    还可以继续透视它的更内层:

        console.log(add.params[0])
        Identifier {
            type: 'Identifier',
            name: 'a'
            loc: ...
        }
    
        console.log(add.body.body[0].argument.left)
        Identifier {
            type: 'Identifier',
            name: 'a'
            loc: ...
        }
    

    制作模具

    一个机器,你只会拆开重装,不算本事。拆开了,还能改装,才算上得了台面。
    recast.types.builders 里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

    最简单的例子,把之前的函数add声明,改成匿名函数式声明const add = function(a ,b) {...}

    • 创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。
    • 创建一个VariableDeclarator,声明头为add.id, 右边是将创建的FunctionDeclaration对象。
    • 创建一个FunctionDeclaration,如前所述的三个组件 id、params、body 中,因为是匿名函数,id 设为空,params使用add.paramsbody使用add.body

    加入parse.js

        // 引入变量声明,变量符号,函数声明三种“模具”
        const { variableDeclaration, variableDeclarator, functionExpression } = recast.types.builders;
    
        // 将准备好的组件置入模具,并组装回原来的ast对象。
        ast.program.body[0] = variableDeclaration("const", [
            variableDeclarator(add.id, functionExpression(
                null, // Anonymize the function expression.
                add.params,
                add.body
            ))
        ]);
    
        //将AST对象重新转回可以阅读的代码
        const output = recast.print(ast).code;
    
        console.log(output)
    >>>>>打印所得:
    const add = function(a, b) {
      return a +
        // 有什么奇怪的东西混进来了
        b
    };
    

    其中,const output = recast.print(ast).code 其实是recast.parse的逆向过程,具体公式为:

        recast.print(recast.parse(source)).code === source
    

    打印出美化格式的代码段:

        const output = recast.prettyPrint(ast, { tabWidth: 2 }).code;
    >>>>
    const add = function(a, b) {
       return a + b;
    };
    

    命令行修改JS文件

    除了parse/print/builder之外,recast的三项主要功能:

    • run:通过命令行读取JS文件,并转化成 ast 以供处理;
    • tnt:通过assert()check(),可以验证 ast 对象的类型;
    • visit:遍历 ast 树,获取有效的 AST 对象并进行更改。

    新建 demo.js

           function add(a, b) {
               return a + b
           }
    
           function sub(a, b) {
               return a - b
           }
    
           function commonDivision(a, b) {
               while (b !== 0) {
                   if (a > b) {
                       a = sub(a, b)
                   } else {
                       b = sub(b, a)
                   }
               }
               return a
           }
    
    1. recast.run ——命令行文件读取
      新建一个名为 read.js 的文件

      #!/usr/bin/env node
      
      const recast  = require('recast');
      
      recast.run(function(ast, printSource){
          printSource(ast);
      });
      

      命令行输入node read demo.js,可以看到JS文件内容打印在了控制台上。
      我们可以知道,node read 可以读取 demo.js 文件,并将 demo.js 内容转化为ast对象。
      同时它还提供了一个 printSource 函数,随时可以将 ast 的内容转换回源码,以方便调试。

    2. recast.visit ——AST节点遍历
      read.js

      #!/usr/bin/env node
      
      const recast  = require('recast');
      
      recast.run(function(ast, printSource){
          recast.visit(ast, {
              visitExpressionStatement: function({node}) {
                  console.log(node);
                  return false;
              }
          });
      });
      

      recast.visitAST对象内的节点进行逐个遍历。

      • 如果想操作函数声明,就使用 visitFunctionDelaration 遍历,想操作赋值表达式,就使用visitExpressionStatement。 只要在 AST对象文档中定义的对象,在前面加visit,即可遍历。
      • 通过node可以取到AST对象。
      • 每个遍历函数后必须加上return false,或者选择以下写法,否则报错:
      #!/usr/bin/env node
      
      const recast  = require('recast');
      
      recast.run(function(ast, printSource){
          recast.visit(ast, {
              visitExpressionStatement: function(path) {
                  const node = path.node;
                  printSource(node);
                  this.traverse(path);
              }
          });
      });
      

      调试时,如果你想输出AST对象,可以 console.log(node)
      如果想输出AST对象对应的源码,可以printSource(node)
      在所有使用 recast.run() 的文件顶部都需要加入#!/usr/bin/env node

    3. TNT ——判断AST对象类型
      TNT,即recast.types.namedTypes,用来判断AST对象是否为指定的类型。
      TNT.Node.assert(),就像在机器里埋好的炸药,当机器不能完好运转时(类型不匹配),就炸毁机器(报错退出)

      TNT.Node.check(),则可以判断类型是否一致,并输出 False 和 True

      Node可以替换成任意AST对象,例如TNT.ExpressionStatement.check(),TNT.FunctionDeclaration.assert()

      read.js

      #!/usr/bin/env node
      
      const recast  = require('recast');
      const TNT = recast.types.namedTypes;
      
      recast.run(function(ast, printSource){
          recast.visit(ast, {
              visitExpressionStatement: function(path) {
                  const node = path.value;
                  // 判断是否为ExpressionStatement,正确则输出一行字。
                  if(TNT.ExpressionStatement.check(node)){
                     console.log('这是一个ExpressionStatement')
                  }
                  this.traverse(path);
              }
          });
      });
      --------------------------------------------------------
      recast.run(function(ast, printSource){
          recast.visit(ast, {
              visitExpressionStatement: function(path) {
                  const node = path.node;
                  // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
                  TNT.ExpressionStatement.assert(node);
                  this.traverse(path);
              }
          });
      });
      

      命令行输入node read demo.js进行测试。

    用AST修改源码,导出demo.js的全部方法

    除了使用 fs.read 读取文件、正则匹配替换文本、fs.write 写入文件这种笨拙的方式外,我们可以用AST优雅地解决问题。

    • 首先,我们先用builders凭空实现一个键头函数:
      exportific.js

      #!/usr/bin/env node
      
      const recast  = require('recast');
      
      const {
          identifier:id,
          expressionStatement,
          memberExpression,
          assignmentExpression,
          arrowFunctionExpression,
          blockStatement
      } = recast.types.builders;
      
      recast.run(function(ast, printSource) {
          // 一个块级域 {}
          console.log('\n\nstep1:');
          printSource(blockStatement([]));
      
          // 一个键头函数 ()=>{}
          console.log('\n\nstep2:');
          printSource(arrowFunctionExpression([],blockStatement([])));
      
          // add赋值为键头函数  add = ()=>{}
          console.log('\n\nstep3:');
          printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))));
      
          // exports.add赋值为键头函数  exports.add = ()=>{}
          console.log('\n\nstep4:');
          printSource(expressionStatement(assignmentExpression('=', memberExpression(id('exports'), id('add')), 
                  arrowFunctionExpression([], blockStatement([])))));
      });
      

      我们一步一步推断出 exports.add = ()=>{} 的过程,从而得到具体的AST结构体。
      执行 node exportific demo.js 命令查看:

      step1:
      {}
      
      step2:
      () => {}
      
      step3:
      add = () => {}
      
      step4:
      exports.add = () => {};
      
    • 接下来,只需要在获得的最终的表达式中,把 id('add') 替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把 blockStatement([]) 替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

      另外,需要注意的是,在demo.jscommonDivision 函数内,引用了sub 函数,应改写成 exports.sub

    exportific.js

    ```
    #!/usr/bin/env node
    const recast = require("recast");
    const {
        identifier: id,
        expressionStatement,
        memberExpression,
        assignmentExpression,
        arrowFunctionExpression
    } = recast.types.builders
    
    recast.run(function (ast, printSource) {
        // 用来保存遍历到的全部函数名
        let funcIds = []
        recast.types.visit(ast, {
            // 遍历所有的函数定义
            visitFunctionDeclaration(path) {
                //获取遍历到的函数名、参数、块级域
                const node = path.node
                const funcName = node.id
                const params = node.params
                const body = node.body
    
                // 保存函数名
                funcIds.push(funcName.name)
                // 这是上一步推导出来的ast结构体
                const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
                        arrowFunctionExpression(params, body)))
                // 将原来函数的ast结构体,替换成推导ast结构体
                path.replace(rep)
                // 停止遍历
                return false
            }
        })
    
        recast.types.visit(ast, {
            // 遍历所有的函数调用
            visitCallExpression(path){
                const node = path.node;
                // 如果函数调用出现在函数定义中,则修改ast结构
                if (funcIds.includes(node.callee.name)) {
                    node.callee = memberExpression(id('exports'), node.callee)
                }
                // 停止遍历
                return false
            }
        })
        // 打印修改后的ast源码
        printSource(ast)
    })
    ```
    

    一步到位,发一个最简单的exportific前端工具

    上面讲了那么多,仍然只体现在理论阶段。
    但通过简单的改写,就能通过 recast 制作成一个名为 exportific 的源码编辑工具。
    以下代码添加作了两个小改动:

    • 添加说明书--help,以及添加了 --rewrite 模式,可以直接覆盖文件或默认为导出 *.export.js 文件。
    • 将之前代码最后的 printSource(ast) 替换成 writeASTFile(ast, filename, rewriteMode)

    exportific.js

    #!/usr/bin/env node
    const recast = require("recast");
    const {
        identifier: id,
        expressionStatement,
        memberExpression,
        assignmentExpression,
        arrowFunctionExpression
    } = recast.types.builders
    
    const fs = require('fs')
    const path = require('path')
    // 截取参数
    const options = process.argv.slice(2)
    
    //如果没有参数,或提供了-h 或--help选项,则打印帮助
    if(options.length===0 || options.includes('-h') || options.includes('--help')){
        console.log(`
            采用commonjs规则,将.js文件内所有函数修改为导出形式。
    
            选项: -r  或 --rewrite 可直接覆盖原有文件
        `)
        process.exit(0)
    }
    
    // 只要有-r 或--rewrite参数,则rewriteMode为true
    let rewriteMode = options.includes('-r') || options.includes('--rewrite')
    
    // 获取文件名
    const clearFileArg = options.filter((item)=>{
        return !['-r','--rewrite','-h','--help'].includes(item)
    })
    
    // 只处理一个文件
    let filename = clearFileArg[0]
    
    const writeASTFile = function(ast, filename, rewriteMode){
        const newCode = recast.print(ast).code
        if(!rewriteMode){
            // 非覆盖模式下,将新文件写入*.export.js下
            filename = filename.split('.').slice(0,-1).concat(['export','js']).join('.')
        }
        // 将新代码写入文件
        fs.writeFileSync(path.join(process.cwd(),filename),newCode)
    }
    
    recast.run(function (ast, printSource) {
        let funcIds = []
        recast.types.visit(ast, {
            visitFunctionDeclaration(path) {
                //获取遍历到的函数名、参数、块级域
                const node = path.node
                const funcName = node.id
                const params = node.params
                const body = node.body
    
                funcIds.push(funcName.name)
                const rep = expressionStatement(assignmentExpression('=', memberExpression(id('exports'), funcName),
                            arrowFunctionExpression(params, body)))
                path.replace(rep)
                return false
            }
        })
    
        recast.types.visit(ast, {
            visitCallExpression(path){
                const node = path.node;
                if (funcIds.includes(node.callee.name)) {
                    node.callee = memberExpression(id('exports'), node.callee)
                }
                return false
            }
        })
    
        writeASTFile(ast,filename,rewriteMode)
    })
    

    运行一下:node exportific demo.js,得到文件demo.export.js

    exports.add = (a, b) => {
        return a + b
    };
    
    exports.sub = (a, b) => {
        return a - b
    };
    
    exports.commonDivision = (a, b) => {
        while (b !== 0) {
            if (a > b) {
                a = exports.sub(a, b)
            } else {
                b = exports.sub(b, a)
            }
        }
        return a
    };

    相关文章

      网友评论

          本文标题:初探AST-抽象语法树

          本文链接:https://www.haomeiwen.com/subject/osqmactx.html