image.png

AST概念

AST(Abstract Syntax Tree) ,抽象语法树,或者语法树(syntax tree),是源代码的抽象语法结构的树状表现形式。
为什么要好好的源代码搞成树状的形式?因为统一的结构化的形式,有助于对源代码进行后续分析和处理。
比如下面这句代码:

  1. import xx from 'jq'

经过AST转换后,我们得到这样的形式:

  1. {
  2. "type": "Program",
  3. "start": 0,
  4. "end": 19,
  5. "body": [
  6. {
  7. "type": "ImportDeclaration",
  8. "start": 0,
  9. "end": 19,
  10. "specifiers": [
  11. {
  12. "type": "ImportDefaultSpecifier",
  13. "start": 7,
  14. "end": 9,
  15. "local": {
  16. "type": "Identifier",
  17. "start": 7,
  18. "end": 9,
  19. "name": "xx"
  20. }
  21. }
  22. ],
  23. "source": {
  24. "type": "Literal",
  25. "start": 15,
  26. "end": 19,
  27. "value": "jq",
  28. "raw": "'jq'"
  29. }
  30. }
  31. ],
  32. "sourceType": "module"
  33. }

简单的看下这个结构不难发现,似乎将我们的每一个词语都拆开了,用了很多描述的属性来描述。比如,body中现在只有一个对象(因为我们只有一句话暂时)这个对象的type:ImportDeclaration顾名思义就是Import的声明语句,然后我们看他的specifiers,标识符也有type: ImportDefaultSpecifier,import的default标识符(显然,如果我们引入不是default引入而是{xx}, 其type会变成ImportSpecifier),然后local字段表示在当前文件的变量名,比如我们这个xx,类型就是Identifier, 名字就是xx。后面继续看source,就是这个Import的声明语句的被引入源的信息。
还有一点值得注意的是,每一处要点信息都有start、end表示在这句字符串中的位置信息。这为处理字符串提供了很大的方便。

词法分析和语法分析

上面就一句代码,生成AST如此庞大,虽然啰嗦,但是全面。那么,AST 如何生成?
生成大致分为两个核心步骤:

  • 词法分析 (Lexical Analysis):扫描输入的源代码字符串,生成一系列的词法单元 (tokens)。这些词法单元包括数字,标点符号,运算符等。词法单元之间都是独立的,暂时不考虑如何组合以及组合的含义。
  • 语法分析 (Syntax Analysis):建立分析语法单元之间的关系,这个阶段就能得到完整的AST了。

上面的说法比较官方,不直观,借用一个通俗的例子(参考自这里
比如分析:它是猪
在词法分析阶段,得到孤立的token:

  1. [
  2. {type: '主语', value: '它'},
  3. {type: '谓语', value: '是'}
  4. {type: '宾语', value: '猪'}
  5. ]

然后经过语法分析之后,生成有意义的结构化的树:

  1. {
  2. type: '语句',
  3. body:[
  4. {
  5. type: '陈述句',
  6. declaration: [
  7. {
  8. type: '声明',
  9. id: {
  10. type: 'Identifier',
  11. name: '它'
  12. },
  13. init: {
  14. type: 'Literal',
  15. name: '猪',
  16. raw: '猪'
  17. }
  18. }
  19. ]
  20. }
  21. ]
  22. }

这里只是一个DEMO,真的的AST规范和说明可以参见这篇文章:https://juejin.cn/post/6844903450287800327#heading-3

AST有什么用

AST的作用一般不会体现在业务代码之中,但却它隐式存在在前端程序员开发的各个方面:

  • IDE 的错误提示、代码美化、代码高亮、代码自动补全等
  • eslint对代码错误或风格的检查等
  • webpack、rollup 进行代码打包等
  • TypeScript、JSX 等转化为原生 Javascript
  • bable转化高版本JS为低版本
  • vue 模板编译

相关的工具和库

astexplorer

https://astexplorer.net/
这是一个在线的AST转换工具,将源代码输入到左边,右边就会显示转换之后的AST。而且源语言不限于JS,转换目标格式默认是acorn,也可以选择。

babel-types

这是一篇文档,列出了AST中的类型
https://babeljs.io/docs/en/babel-types
常用类型(参考自:这里 ):

类型名称 中文译名 描述
Program 程序主体 整段代码的主体
VariableDeclaration 变量声明 声明变量,比如 let const var
FunctionDeclaration 函数声明 声明函数,比如 function
ExpressionStatement 表达式语句 通常为调用一个函数,比如 console.log(1)
BlockStatement 块语句 包裹在 {} 内的语句,比如 if (true) { console.log(1) }
BreakStatement 中断语句 通常指 break
ContinueStatement 持续语句 通常指 continue
ReturnStatement 返回语句 通常指 return
SwitchStatement Switch 语句 通常指 switch
IfStatement If 控制流语句 通常指 if (true) {} else {}
Identifier 标识符 标识,比如声明变量语句中 const a = 1 中的 a
ArrayExpression 数组表达式 通常指一个数组,比如 [1, 2, 3]
StringLiteral 字符型字面量 通常指字符串类型的字面量,比如 const a = ‘1’ 中的 ‘1’
NumericLiteral 数字型字面量 通常指数字类型的字面量,比如 const a = 1 中的 1
ImportDeclaration 引入声明 声明引入,比如 import

babel

babel作为转换代码转换工具,原理也是利用了AST。babel自身也有AST相关的工具:

下面的代码演示了如何去除源码中的debugger, 思路是利用parser.parse生成AST,利用traverse在遍历的过程中,删除DebuggerStatement类型的tree节点,最终再通过generator.default反向生成JS的代码:

  1. const parser = require('@babel/parser');
  2. const traverse = require("@babel/traverse");
  3. const generator = require("@babel/generator");
  4. // 源代码
  5. const code = `
  6. function fn() {
  7. console.log('debugger')
  8. debugger;
  9. }
  10. `;
  11. // 1. 源代码解析成 ast
  12. const ast = parser.parse(code);
  13. // 2. 转换
  14. const visitor = {
  15. // traverse 会遍历树节点,只要节点的 type 在 visitor 对象中出现,变化调用该方法
  16. DebuggerStatement(path) {
  17. // 删除该抽象语法树节点
  18. path.remove();
  19. }
  20. }
  21. traverse.default(ast, visitor);
  22. // 3. 生成
  23. const result = generator.default(ast, {}, code);
  24. console.log(result.code)
  25. // 4. 日志输出
  26. // function fn() {
  27. // console.log('debugger');
  28. // }

esprima

这是一个解析code为AST的库
https://esprima.org/doc/index.html#
下面代码演示了利用esprima解析AST,利用estraverse遍历AST的,利用escodegen反向生成js code的代码,代码把const变成var:

  1. const esprima = require('esprima');
  2. const estraverse = require('estraverse');
  3. const escodegen = require('escodegen');
  4. let code = 'const a = 1';
  5. const ast = esprima.parseScript(code);
  6. estraverse.traverse(ast, {
  7. enter: function (node) {
  8. node.kind = "var";
  9. }
  10. });
  11. const transformCode = escodegen.generate(ast);
  12. console.log(transformCode);

recast

https://github.com/benjamn/recast

  1. // parse.js
  2. const recast = require('recast')
  3. const code = `function add (a, b) {
  4. return a + b
  5. }`
  6. const ast = recast.parse(code)
  7. // 获取代码块 ast 的第一个 body,即我们的 add 函数
  8. const add = ast.program.body[0]
  9. console.log(add)
  10. // print:
  11. // FunctionDeclaration {
  12. // type: 'FunctionDeclaration',
  13. // id: Identifier...,
  14. // params: [Identifier...],
  15. // body: BlockStatement...
  16. // }

这个库的方法很多,除了能建立AST之外,利用recast.types.builders可以操作修改AST,或者利用recast.visit遍历AST树。
recast的用法参考官网或者这些文章:
https://juejin.cn/post/6844903910960791566#heading-3
https://segmentfault.com/a/1190000016231512

jscodeshift

https://github.com/facebook/jscodeshift
jscodeshift封装了recast,比之API设计的更为友好一点,可以比较方便的对AST中的节点进行增删查改,可以参照这篇文章:https://juejin.cn/post/6942016231214055454#heading-12

acorn

acorn是一个解析AST的库,解析的结果符合 The Estree Spec 规范(这是 Mozilla 的工程师给出的 SpiderMonkey 引擎输出的 JavaScript AST 的规范文档,也可以参考:SpiderMonkey in MDN)。
webpack、rollup解析AST都用的是acorn。
https://github.com/acornjs/acorn

这里有一文介绍acorn可以参考:https://juejin.cn/post/6844903450287800327#heading-1

我们使用acorn,一句acorn.parse就搞定了

  1. const ast = acorn.parse( code, {
  2. locations: true,
  3. ranges: true,
  4. sourceType: 'module',
  5. ecmaVersion: 8
  6. });

不过需要做点什么的时候,需要便利AST,不过acorn不提供遍历的方法(有acorn-walk),不过我们可以尝试自己写一下,假设我们期望这样遍历ast :

  1. ast.body.forEach(statement =>
  2. walk(statement, {
  3. enter(node) {
  4. // enter
  5. },
  6. leave(node) {
  7. // leave
  8. }
  9. });
  10. });

那么实现下walk就是这样(这是rollup_v0.31 /src/ast/walk.js的方法):

  1. function walk(ast, { enter, leave }) {
  2. visit(ast, null, enter, leave)
  3. }
  4. function visit(node, parent, enter, leave) {
  5. if (enter) {
  6. enter.call(null, node, parent)
  7. }
  8. let keys = Object.keys(node).filter(key => typeof node[key] === 'object');
  9. keys.forEach(key => {
  10. let value = node[key];
  11. if (Array.isArray(value)) {
  12. value.forEach(val => {
  13. visit(val, node, enter, leave);
  14. });
  15. } else if (value && value.type) {
  16. visit(value, node, enter, leave);
  17. }
  18. });
  19. if (leave) {
  20. leave(node, parent)
  21. }
  22. }

其他和参考

很遗憾,有些参考文章的demo只能简单记录下要点而不能亲自实践了。

案例:jscodeshift + commander 实现命令行处理css后缀名

参考文章:https://juejin.cn/post/6942016231214055454#heading-17

在对 jscodeshift 有了初步了解之后,我们接下来做一个命令行工具来解决我在上面提出的“引入样式文件后缀名问题”,接下来会简单使用到 commander ,它使 nodejs 命令行接口变得更简单~ import ‘./style.scss’ ,全部转换成以 .css

案例:开发babel-loader

参考文章:https://juejin.cn/post/6844903910960791566#heading-15

AST遍历

这篇文章专门介绍AST的遍历和遍历工具:https://juejin.cn/post/7016711681158053925
介绍了一种通用的遍历AST的思路:visitorKeys,另外,分析了 babel、eslint、tsc、estraverse、postcss 这些工具都是怎么遍历 AST 的。