zoukankan      html  css  js  c++  java
  • AST抽象语法树 Javascript版

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

    现在,我们拆解一个简单的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,它成了三部分,leftoperatorright

    • operator+
    • left 里面装的,是Identifier对象 a
    • right 里面装的,是Identifer对象 b

    就这样,我们把一个简单的add函数拆解完毕。

    抽象语法树(Abstract Syntax Tree),的确是一种标准的树结构。

    那么,上面我们提到的Identifier、Blockstatement、ReturnStatement、BinaryExpression, 这一个个小部件的说明书去哪查?

    请查看 AST对象文档

    recast

    输入命令:npm i recast -S

    你即可获得一把操纵语法树的螺丝刀

    接下来,你可以在任意js文件下操纵这把螺丝刀,我们新建一个parse.js示意:

    创建parse.js文件

    // 给你一把"螺丝刀"——recast
    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函数的结构,与之前所述一致,通过AST对象文档可查到它的具体属性:

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

    recast.types.builders 制作模具

    recast.types.builders里面提供了不少“模具”,让你可以轻松地拼接成新的机器。

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

    如何改装?

    第一步,我们创建一个VariableDeclaration变量声明对象,声明头为const, 内容为一个即将创建的VariableDeclarator对象。

    第二步,创建一个VariableDeclarator,放置add.id在左边, 右边是将创建的FunctionDeclaration对象

    第三步,我们创建一个FunctionDeclaration,如前所述的三个组件,id params body中,因为是匿名函数id设为空,params使用add.params,body使用add.body。

    这样,就创建好了const add = function(){}的AST对象。

    在之前的parse.js代码之后,加入以下代码

    // 引入变量声明,变量符号,函数声明三种“模具”
    const {variableDeclaration, variableDeclarator, functionExpression} = recast.types.builders
    
    // 将准备好的组件置入模具,并组装回原来的ast对象。
    ast.program.body[0] = variableDeclaration("const", [
      variableDeclarator(add.id, functionExpression(
        null, // 匿名化函数表达式.
        add.params,
        add.body
      ))
    ]);
    
    //将AST对象重新转回可以阅读的代码
    //这一行其实是recast.parse的逆向过程,具体公式为
    //recast.print(recast.parse(source)).code === source
    const output = recast.print(ast).code;
    
    console.log(output)
    

    打印出来还保留着“原装”的函数内容,连注释都没有变。

    我们其实也可以打印出美化格式的代码段:

    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对象并进行更改。

    通过一个系列小务来学习全部的recast工具库:

    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
    }
    

    recast.run 命令行文件读取

    新建一个名为read.js的文件,写入

    read.js

    recast.run( function(ast, printSource){
        printSource(ast)
    })
    

    命令行输入

    node read demo.js

    我们查以看到js文件内容打印在了控制台上。

    我们可以知道,node read可以读取demo.js文件,并将demo.js内容转化为ast对象。

    同时它还提供了一个printSource函数,随时可以将ast的内容转换回源码,以方便调试。

    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.visit将AST对象内的节点进行逐个遍历。

    注意

    • 你想操作函数声明,就使用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)

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

    #!/usr/bin/env node 在所有使用recast.run()的文件顶部都需要加入这一行,它的意义我们最后再讨论。

    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);
          }
        });
    });
    

    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.node
            // 判断是否为ExpressionStatement,正确不输出,错误则全局报错
            TNT.ExpressionStatement.assert(node)
            this.traverse(path);
          }
        });
    });
    

    实战:用AST修改源码,导出全部方法

    exportific.js

    现在,我们想让这个文件中的函数改写成能够全部导出的形式,例如

    function add (a, b) {
        return a + b
    }
    

    想改变为

    exports.add = (a, b) => {
      return a + b
    }
    

    首先,我们先用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('
    
    step1:')
      printSource(blockStatement([]))
    
      // 一个键头函数 ()=>{}
      console.log('
    
    step2:')
      printSource(arrowFunctionExpression([],blockStatement([])))
    
      // add赋值为键头函数  add = ()=>{}
      console.log('
    
    step3:')
      printSource(assignmentExpression('=',id('add'),arrowFunctionExpression([],blockStatement([]))))
    
      // exports.add赋值为键头函数  exports.add = ()=>{}
      console.log('
    
    step4:')
      printSource(expressionStatement(assignmentExpression('=',memberExpression(id('exports'),id('add')),
        arrowFunctionExpression([],blockStatement([])))))
    });
    

    上面写了我们一步一步推断出exports.add = ()=>{}的过程,从而得到具体的AST结构体。

    使用node exportific demo.js运行可查看结果。

    接下来,只需要在获得的最终的表达式中,把id('add')替换成遍历得到的函数名,把参数替换成遍历得到的函数参数,把blockStatement([])替换为遍历得到的函数块级作用域,就成功地改写了所有函数!

    另外,我们需要注意,在commonDivision函数内,引用了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前端工具

    以下代码添加作了两个小改动

    1. 添加说明书--help,以及添加了--rewrite模式,可以直接覆盖文件或默认为导出*.export.js文件。
    2. 将之前代码最后的 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文件了。

    npm发包

    编辑一下package.json文件

    {
      "name": "exportific",
      "version": "0.0.1",
      "description": "改写源码中的函数为可exports.XXX形式",
      "main": "exportific.js",
      "bin": {
        "exportific": "./exportific.js"
      },
      "keywords": [],
      "author": "wanthering",
      "license": "ISC",
      "dependencies": {
        "recast": "^0.15.3"
      }
    }
    

    注意bin选项,它的意思是将全局命令exportific指向当前目录下的exportific.js

    这时,输入npm link 就在本地生成了一个exportific命令。

    之后,只要哪个js文件想导出来使用,就exportific XXX.js一下。

    一定要注意exportific.js文件头有

    #!/usr/bin/env node

    接下来,正式发布npm包!

    如果你已经有了npm 帐号,请使用npm login登录

    如果你还没有npm帐号 https://www.npmjs.com/signup 非常简单就可以注册npm

    然后,输入
    npm publish

    没有任何繁琐步骤,丝毫审核都没有,你就发布了一个实用的前端小工具exportific 。任何人都可以通过

    npm i exportific -g
    

    全局安装这一个插件。

    提示:在试验教程时,请不要和我的包重名,修改一下发包名称。

    #!/usr/bin/env node

    不同用户或者不同的脚本解释器有可能安装在不同的目录下,系统如何知道要去哪里找你的解释程序呢? /usr/bin/env就是告诉系统可以在PATH目录中查找。 所以配置#!/usr/bin/env node, 就是解决了不同的用户node路径不同的问题,可以让系统动态的去查找node来执行你的脚本文件。

    如果出现No such file or directory的错误?因为你的node安装路径没有添加到系统的PATH中。所以去进行node环境变量配置就可以了。

    要是你只是想简单的测试一下,那么你可以通过which node命令来找到你本地的node安装路径,将/usr/bin/env改为你查找到的node路径即可。


    参考文章:https://segmentfault.com/a/1190000016231512?utm_source=tag-newest#comment-area

  • 相关阅读:
    Asp.net Json 解析 与 直接用ip访问返回josn
    mysql 时间函数
    Android之TelephonyManager类的使用案例
    android:screenOrientation的说明 固定屏幕显示方向
    在android.app.Application中定义全局变量
    Android开发实例详解之IMF(Android SDK Sample—SoftKeyboard)
    Android InputMethodManager输入法简介
    android ImageView的属性android:scaleType,即ImageView.setScaleType(ImageView.ScaleType)
    VS编译常见错误枚举01
    Visual C++ Samples-------------Code Project
  • 原文地址:https://www.cnblogs.com/rope/p/11934468.html
Copyright © 2011-2022 走看看