初探AST-抽象语法树

2020-01-13  本文已影响0人  hellomyshadow

中文原文: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
    }

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

    {
        name: 'add'
        type: 'identifier'
        ...
    }
    [
        {
            name: 'a'
            type: 'identifier'
            ...
        },
        {
            name: 'b'
            type: 'identifier'
            ...
        }
    ]

拆解之后,用图表示:

拆解图.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) {...}

加入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的三项主要功能:

新建 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优雅地解决问题。

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 的源码编辑工具。
以下代码添加作了两个小改动:

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
};
上一篇下一篇

猜你喜欢

热点阅读