AST,其实很简单

916 阅读8分钟

1. AST 是什么?

Abstract Syntax Tree,抽象语法树,简称 AST。

在 Javascript 代码执行流程中,它位于词法和语法分析之后。

通过对源代码进行词法和语法分析,生成一颗「树」。

打开 astexplorer.net,随便输入一段 Javascript 代码,就可以看到其对应的 AST。

2. AST 有什么用?

点击右边的 JSON tab 切换到 JSON 视图,program 部分:

就算不借助任何其他工具,我们也可以通过遍历这个 JSON 结构还原出它对应的源码。

所以也可以在还原的时候顺便把 const 的部分修改为 var,这样我们的代码就能在旧版浏览器上跑了,这就是 babel 所做的事情。

借助现有的工具,我们不需要自己去遍历和生成代码,对 AST 的增删改查变得很简单。

3. @babel/parser、@babel/traverse 和 @babel/generator

  • @babel/parser:通过源码生成 AST,上面在 astexplorer 里用的就是它。
const parse = require('@babel/parser').parse

const code = `const a = 1`

const ast = parse(code)

我们可以在这里找到它的文档,也可以在 node_modules 下面找到它的包,里面有提供 typings 能查看它接收的参数。

  • @babel/traverse:遍历 AST 的工具。但是它的官方文档只有 installusage,没有更详细的文档了,node_modules 下面的包也没有提供 types 文件。那我们到底要怎样去遍历 AST 呢?

这里就不绕弯子了,虽然我本人在这里绕了挺久。其实很简单,回到上面的 astexplorer.net,每个节点都有一个 type 字段来标识它的类型,比如我们的示例里面 const a = 1 这个节点的 type 值就是 VariableDeclaration,鼠标点击左边代码,会自动把右边对应的节点高亮:

@babel/traverse 第一个参数就是通过 parse 生成的 AST,第二个参数叫做 visitors。traverse 会遍历节点,每遇到一个节点,调用 visitors 里面对应的处理函数,而这个函数的 key 就是节点的 type。

const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default

const code = `const a = 1`

const ast = parse(code)

traverse(ast, {
  VariableDeclaration(path) {
    console.log(Object.keys(path))
  }
})

传入参数 path 并不是节点本身,而是一个包含节点信息的对象,我们看一下它的 key 有哪些:

其中 path.node 才是节点,打印出来和 astexplorer 上面是一样的。这里我们要关注的其他 key 是 parentparentPathparent 是父节点,在这里它是 type 为 Program 的节点,而 parentPath 就是包含父节点信息的对象了。他们的关系就和 pathnode 的关系一样:

traverse(ast, {
  VariableDeclaration(path) {
    console.log(path.parent === path.parentPath.node) // true
  }
})

简单来说,在 AST 上看到的所有 type 值,你都可以写在 visitors 里从而访问到对应的节点。

下面我们把 const 换成 var。首先,我们已经知道 const a = 1 的 AST 了,那 var a = 1 的 AST 长什么样呢?直接在 astexplorer 里面看看不就知道了:

有了 AST 的对比,我们就可以放开手脚开始改了,这里我们直接把 kind 的值从 const 改成 var

traverse(ast, {
  VariableDeclaration(path) {
    path.node.kind = 'var'
  }
})

AST 修改好了,接下来就是要从 AST 还原为代码,然后就可以放到旧版浏览器上跑了。

  • @babel/generator:根据 AST 生成代码。这里是它的文档,根据文档来看,我们甚至可以把两棵 AST 合并生成一个文件,并且生成 sourceMaps。

将我们上面修改的 AST 生成代码:

const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default

const code = `const a = 1`

const ast = parse(code)

traverse(ast, {
  VariableDeclaration(path) {
    path.node.kind = 'var'
  }
})

const result = generate(ast)

console.log(result.code) // var a = 1;

4. AST 增删改查

@babel/types:操作 AST 节点,离不开 @babel/types 这个包。在这里你能找到它的文档,我们要用它来创建节点和判断节点类型。

就如上面所说,通过 visitors 可以访问到各种 type 的节点。除了 type 之外,还有一些访问节点的方法。

enterexit。它们不是 type 类型,但是每个节点都会调用 enter 和 exit 各一次,所以不要轻易使用,它们很消耗性能,尽量用 type 去遍历特定的节点。

traverse(ast, {    
  enter(path) {
    console.log("Entered!");
  },
  exit(path) {
    console.log("Exited!");
  }
})

alias 别名可以访问拥有相同别名的节点,比如用 Function 可以访问到FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, ObjectMethodClassMethod这些节点。那节点的别名在哪里看呢?没找到文档,但是你可以去查看源码定义了哪些别名,比如 ES2015 里 ClassMethod 定义的别名就有:

另外在访问节点时,我们经常需要判断子节点的类型。通过 path 提供的方法 get 可以快速获取到子节点的 path 信息(注意不是子节点 node)。配合 @babel/types 提供的类型判断方法可以更简单获取节点类型:

traverse(ast, {
  VariableDeclaration(path) {
    const node = path.node
    // 以 . 连接的形式快速获取子节点 path
    const arrowPath = path.get('declarations.0.init')
    // 判断节点类型的函数命名为 is+节点类型
    const isArrow = t.isArrowFunctionExpression(arrowPath)
  }
})

同时也提供了查找父节点 path 的方法 path.findParent()

traverse(ast, {
  VariableDeclaration(path) {
      const objectParent = path.findParent(path => t.isObjectProperty(path))
    }
  }
})

删除永远都很简单,打开 astexplorer.net/,输入你的源代码,找到要删除的代码对应的节点:

然后 traverse 访问节点调用 path.remove()即可:

const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const t = require('@babel/types')

const code = `
const a = 1;
console.log(a);
const b = 2;
`
const ast = parse(code)

traverse(ast, {
  CallExpression(path) {
    const callee = path.node.callee
    if (callee.object.name === 'console' && callee.property.name === 'log') {
      // 调用 path.remove() 最后出来的结果是一样的,但是会留下一个空 prarent 节点
      path.parentPath.remove() 
    }
  },

  // 下面的代码是一样的效果,但是看起来更直观一点
  MemberExpression(path) {
    if (path.node.object.name === 'console' && path.node.property.name === 'log') {
      const parent = path.findParent(path => t.isExpressionStatement(path))
      parent.remove()
    }
  }
})

const result = generate(ast)

console.log(result.code)

// const a = 1;
// const b = 2;
  • 改和增

如果只是改节点现有的 key,比如函数名和变量名,那直接覆盖原来的值就行。但是形如把箭头函数改成 function 这种就涉及到新增 AST 节点了,所以把改和增放在一起讲。

这里我们尝试构建一个 function a() { return 1 }的节点。

首先,打开 astexplorer.net/,我们在操作 AST 的过程中通过它来预览是最便捷的。输入我们要构建的节点,然后看生成的树:

可以看到节点类型是 FunctionDeclaration,然后去 @babel/types的文档找到 functionDeclaration函数,函数名字对应 type 的小驼峰命名,通过它来生成我们要的节点:

开始构建节点,根据函数签名和 AST 结构,我们需要一层层生成对应的子节点,而每种节点都有对应的生成函数:

const generate = require('@babel/generator').default
const t = require('@babel/types')

const node = t.functionDeclaration(
  t.identifier('a'),
  [],
  t.blockStatement([
    t.returnStatement(t.numericLiteral(1))
  ])
)

const result = generate(node)

console.log(result.code)
/*
function a() {
  return 1;
}
*/

现在,我们终于可以尝试把箭头函数修改成 function 声明了:

const parse = require('@babel/parser').parse
const traverse = require('@babel/traverse').default
const generate = require('@babel/generator').default
const t = require('@babel/types')

const code = `
const a = (b, c) => b + c;
`
const ast = parse(code)

traverse(ast, {
  // 遍历变量声明节点
  VariableDeclaration(path) {
    const node = path.node
    // 判断声明类型是箭头函数
    if (t.isArrowFunctionExpression(path.get('declarations.0.init'))) {
      const declaration = node.declarations[0]
      // 直接复制原来可用的节点,免去构造步骤
      const name = declaration.id
      const params = declaration.init.params
      const returnBlock = declaration.init.body

      const newNode = t.functionDeclaration(
        name,
        params,
        t.blockStatement([
          t.returnStatement(returnBlock)
        ])
      )
      path.replaceWith(newNode)
    }
  }
})

const result = generate(ast)

console.log(result.code)
/*
function a(b, c) {
  return b + c;
}
*/

替换节点 path.replaceWith(newNode),替换为多个节点 path.replaceWithMultiple([node1, node2])

插入节点的方法 path.insertBefore(node) path.insertAfter(node)。工欲善其事必先利其器,知道了节点的操作方法才能更高效的修改 AST。比如我在知道 path.replaceWidth 之前都是先插入新的节点再 remove 当前节点 。

5. 写一个 babel-plugin

现在来看 babel-plugin 官方文档的例子就可以理解了:

export default function() {
  return {
    visitor: {
      Identifier(path) {
        const name = path.node.name;
        // reverse the name: JavaScript -> tpircSavaJ
        path.node.name = name
          .split("")
          .reverse()
          .join("");
      },
    },
  };
}

现在尝试实现一个自己的 babel-plugin,它的功能是把 [1, '..', 4] 替换为 [1, 2, 3, 4],我们要做的步骤有:

  1. astexplorer.net/ 对比两个 AST 结构差异
  2. 修改 AST
// 入参为 babel 对象,其中 babel.types 是我们需要用到的
export default function({ types: t }) {
  return {
    visitor: {
      ArrayExpression(path) {
        const elements = path.node.elements
        if (elements.length !== 3) return
        if (!(
              t.isNumericLiteral(elements[0]) && t.isNumericLiteral(elements[2]) && t.isStringLiteral(elements[1])
        )) return
        if (elements[2].value - elements[0].value <= 1) return
        if (elements[1].value !== '..') return;
        const newNode = [
          elements[0]
        ]
        let right = elements[2].value
        let left = elements[0].value + 1
        while (left <= right) {
          newNode.push(t.numericLiteral(left))
          left++
        }
        // 节点没有类型而是数组则可以直接赋值
        path.node.elements = newNode
      }
    },
  };
}
  1. 打包发布到 npm

现在你可安装尝试一下了:

npm install @linghucq/babel-plugin-range-array

修改 babel 配置文件:

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "modules": false,
        "useBuiltIns": "entry",
        "corejs": 3
      }
    ]
  ],
  "plugins": [
    "@linghucq/babel-plugin-range-array",
    "@babel/plugin-syntax-dynamic-import",
    "@babel/plugin-proposal-optional-chaining"
  ]
}

注意:babel-plugin 执行优先于 preset,其中 plugin 是按配置顺序执行,preset 执行顺序与配置顺序相反,参见官方文档

抛砖引玉,如有错漏,还望不吝指正。

PS:这是一年前写的文章了,今天翻出来看看颇有感触。之前一直认为 AST 这东西太过高深,自己工作中又用不到,没必要去学。其实真正学下来发现并没有想象中那么难,造工具是为了提高效率,要让使用它的人能上手(其实 babel 的文档还是差了点,不过是开源的如果嫌弃可以贡献自己的力量)。所以想学什么东西就去学,别怕没用也别怕难,就算最后真的学不会或者学会了也没处用,学习的过程也会让我们受益良多。下一步准备重新捡起大学挂了四年的高数-.-

参考文章:

  1. 浏览器工作原理与实践
  2. babel-handbook (推荐阅读)