关于javascript:AST抽象语法树最基础的-Javascript-重点知识

41次阅读

共计 10975 个字符,预计需要花费 28 分钟才能阅读完成。

(给前端大全加星标,晋升前端技能)

作者:刘羽冲

segmentfault.com/a/1190000016231512

形象语法树(AST),是一个十分根底而重要的知识点,但国内的文档却简直一片空白。

本文将带大家从底层理解 AST, 并且通过公布一个小型前端工具,来带大家理解 AST 的弱小性能

Javascript 就像一台精妙运作的机器,咱们能够用它来实现所有天马行空的构思。

咱们对 javascript 生态一目了然,却常漠视 javascript 自身。这台机器,到底是哪些零部件在反对着它运行?

AST 在日常业务中兴许很难波及到,但当你不止于想做一个工程师,而想做工程师的工程师,写出 vue、react 之类的大型框架,或相似 webpack、vue-cli 前端自动化的工具,或者有批量批改源码的工程需要,那你必须懂得 AST。AST 的能力非常弱小,且能帮你真正吃透 javascript 的语言精华。

事实上,在 javascript 世界中,你能够认为形象语法树 (AST) 是最底层。再往下,就是对于转换和编译的“黑魔法”畛域了。

人生第一次拆解 Javascript

小时候,当咱们拿到一个螺丝刀和一台机器,人生中最令人思念的梦幻时刻便开始了:

咱们把机器,拆成一个一个小整机,一个个齿轮与螺钉,用奇妙的机械原理连接在一起…

当咱们把它从新照不同的形式组装起来,这时,机器从新又跑动了起来——世界在你眼中如获新生。

通过形象语法树解析,咱们能够像童年时拆解玩具一样,透视 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 外面装的,是 Identifer 对象 b

就这样,咱们把一个简略的 add 函数拆解结束,用图示意就是

看!形象语法树(Abstract Syntax Tree),确实是一种规范的树结构。

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

送给你的 AST 螺丝刀:recast

输出命令:

npm i recast -S

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

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

parse.js

// 给你一把 "螺丝刀"——recastconst 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: ...
}

你也能够持续应用 console.log 透视它的更内层,如:

console.log(add.params[0])
console.log(add.body.body[0].argument.left)

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, // 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;};

当初,你是不是曾经产生了“我能够通过 AST 树生成任何 js 代码”的幻觉?我郑重通知你,这不是幻觉。

实战进阶:命令行批改 js 文件

除了 parse/print/builder 以外,Recast 的三项次要性能:

  • run: 通过命令行读取 js 文件,并转化成 ast 以供解决。
  • tnt:通过 assert()和 check(),能够验证 ast 对象的类型。
  • visit: 遍历 ast 树,获取无效的 AST 对象并进行更改。

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

创立一个用来示例文件,假如是 demo.js

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

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

实战:用 AST 批改源码,导出全副办法

exportific.js

当初,咱们心愿将 demo 中的 function 全副

咱们想让这个文件中的函数改写成可能全副导出的模式,例如

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

想扭转为

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

除了应用 fs.read 读取文件、正则匹配替换文本、fs.write 写入文件这种蠢笨的形式外,咱们能够 == 用 AST 优雅地解决问题 ==。

首先,咱们先用 builders 凭空实现一个键头函数

exportific.js

#!/usr/bin/env nodeconst 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 运行可查看后果。

接下来,只须要在取得的最终的表达式中,把 id(‘add’)替换成遍历失去的函数名,把参数替换成遍历失去的函数参数,把 blockStatement([])替换为遍历失去的函数块级作用域,就胜利地改写了所有函数!

另外,咱们须要留神,在 commonDivision 函数内,援用了 sub 函数,应改写成 exports.sub

exportific.js

#!/usr/bin/env nodeconst 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 nodeconst 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 为 truelet 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 一下。

这是在本地的玩法,想和大家一起分享这个前端小工具,只须要公布 npm 包就行了。

同时,肯定要留神 exportific.js 文件头有

#!/usr/bin/env node

否则在应用时将报错。

接下来,正式公布 npm 包!

如果你曾经有了 npm 帐号,请应用 npm login 登录

如果你还没有 npm 帐号 https://www.npmjs.com/signup …

而后,输出
npm publish

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

npm i exportific -g

全局装置这一个插件。

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

结语

咱们对 javascript 再相熟不过,但透过 AST 的视角,最一般的 js 语句,却焕发出精心动魄的美感。你能够通过它批量构建任何 javascript 代码!

童年时,这个世界充斥了离奇的玩具,再一般的货色在你眼中都如同至宝。现在,计算机语言就是你手中的大玩具,一段段 AST 对象的拆分组装,构建出咱们所生存的网络世界。

所以不得不说软件工程师是一个幸福的工作,你心中住的依然是那个午后的少年,永远有有数离奇等你发现,永远有有数幻想等你构建。

我的项目代码:https://github.com/wanthering…

举荐浏览

(点击题目可跳转浏览)

前端与编译原理:用 JS 写一个 JS 解释器

前端码农之变质 — AST(形象语法树)

前端构建:3 类 13 种热门工具的选型参考

感觉本文对你有帮忙?请分享给更多人

关注「前端大全」加星标,晋升前端技能

正文完
 0