likes
comments
collection
share

快来享受AST转换的乐趣

作者站长头像
站长
· 阅读数 10

如果你经常关注前端基础建设,相信你一定对抽象语法树(Abstract Syntax Tree,AST)技术并不陌生。在 Babel、Webpack、Eslint 等工具中,AST 都发挥着重要的作用,可以说你无时无刻不在享受 AST 技术带来的便利。本文将带你走进 AST 这个前端基建和前端工程化利器。

抽象语法树

抽象语法树是源代码语法结构的一种抽象表示,它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。

看完定义,我们直接通过 AST explorer 来看看抽象语法树是什么样的。

快来享受AST转换的乐趣

左侧的源码是一个变量的定义和赋值,右侧是对应的 AST,可以看到它是一个树状结构,树上的每个节点都可以对应到源代码。

再来看个例子:

快来享受AST转换的乐趣

可以看出 AST 不会记录左侧源码中的括号和分隔符等语法信息。

这边引入一个新概念:解析树,也被成为具象语法树(Concret Syntax Tree, 简称 CST),一棵解析树是包含代码所有语法信息的树型结构,它是代码的直接翻译。

而 AST 是抽象的,它无关语法结构,不会记录源语言真实语法中的每个细节,比如分隔符,空白符释等。

为什么会有 AST? 因为现在各种语言语法种类繁多,虽然最终落到计算机的眼中都是 0 和 1,但是编译器需要识别语言,这个时候就需要使用一种通用的数据结构来描述,而 AST 就是那个东西,因为 AST 是真实存在且存在一定逻辑规则的,它小而美,相对 CST 更便于操作。

AST 的生成需要经过词法分析和语法分析,如下图:

快来享受AST转换的乐趣

词法分析,也称之为扫描(scanner),一个个读取字符,然后与定义好的 JavaScript 关键字符做比较,生成对应的 Token。

Token 是一个不可分割的最小单元: 每个关键字是一个 Token ,每个标识符是一个 Token,每个操作符是一个 Token,每个标点符号也都是一个 Token。例如,const foo = 42;。这段程序通常会被分解成为下面这些词法单元:const, foo, =, 42 , ;

语法分析会将词法分析出来的 Token 转化成有语法含义的抽象语法树结构。同时,验证语法,语法如果有错的话,抛出语法错误。

JS Parser

在介绍 JS 解析器前,我们先了解下 Estree,它参考了 Mozilla 浏览器的 SpiderMonkey 引擎的 AST 的标准,然后做了扩充,形成了现在的 AST 标准。

接下来我们看下一些常见的解析器:

  • Esprima:第一个用 JavaScript 编写的符合 EsTree 规范的 JavaScript 的解析器
  • Acorn:符合 EsTree 规范,性能和效率比 Esprima 更胜一筹,支持插件机制来扩充语法
  • @babel/parser: babel 官方的解析器,最初 fork 于 acorn,其构建的插件体系非常强大
  • Espree: eslint、prettier 的默认解析器,最初 fork 于 Esprima ,后来因为 ES6 的快速发展,但 Esprima 短时间内又不支持,后面就基于 acorn 开发了
  • recast:支持解析和代码重新生成,默认解析器为 Esprima,可使用其他解析器,集成了 ast-types(类似 lodash 的 AST 工具库)
  • jscodeshift: 是一个基于 codemod 理念的重构工具,底层依赖 recast, 提供了类似 jQuery API 一样的方式来遍历和操作 AST

快来享受AST转换的乐趣

实例

实例部分将分别使用 acorn、jscodeshift、@babel/parser 这三种解析器来体验下操作 AST 来完成特定的功能。

在操作过程中,我们需要打开 AST explorer 来查看 AST 转换前后的结构。

var 替换成 let

在这个例子中,我们将使用 acorn 来解析生成 AST,acorn-walk 来遍历 AST, 最后使用 escodegen 将 AST 重新生成代码。

AST 中的各个节点我们可以查看 Estree 文档来查看,或者直接在 AST explorer 中查看。

这个例子的目标是把 var foo = 42 转换成 let foo = 42,在刚接触 AST 的时候,我们一般会先对比 AST 转换前后的结构,找出不同后,再进行代码编码。

先看下 var foo = 42 的 AST 结构:

快来享受AST转换的乐趣

再来看下 let foo = 42 的 AST 结构:

快来享受AST转换的乐趣

对比后发现前后只是在 VariableDeclaration 节点的 kind 属性有差别,故我们只需遍历 AST,找到 VariableDeclaration 节点,调整它的 kind 属性。

// ast.js,node ast 即可输出结果
const { parse } = require('acorn')
const { simple } = require('acorn-walk')
const { generate } = require('escodegen')

const str = 'var foo = 42'
const ast = parse(str, {
  ecmaVersion: 6
})

simple(ast, {
  VariableDeclaration(node) {
    node.kind = 'let'
  }
})

console.log(generate(ast))

acorn 的 parse 方法可以将源码解析为 AST,第一个参数为源码,第二个参数支持解析时一些配置,比如 ecmaVersion,我们将其设置为 6,代码我们源码是 ES6 版本。

解析出 AST 后,我们使用 acorn-walk 中的 simple 方法去遍历,simple 方法支持访问 AST 中的特定节点,除了 simple 方法, acorn-walk 还提供 full 方法,支持完整遍历 AST。

在这个例子中,我们只需访问 VariableDeclaration 节点,故我们配置个 VariableDeclaration 节点,将其 kind 属性调整为 let。

最后我们通过 escodegen 的 generate 方法将 AST 转成代码。

代码重构

这个例子是将没有重新赋值的 let 声明改成 const 声明,我们将使用 jscodeshift 来进行代码重构。上述有介绍过 jscodeshift 基于 recast, 并提供了类似 jQuery API 一样的方式来遍历和操作 AST。

同样的我们先看看对应的 AST 结构:

快来享受AST转换的乐趣

左侧是源码,右侧 body 数组中 4 个对应源码的每一行,这次转换的关键在于要找出所有 let 变量,然后再遍历赋值语句,找出有重新赋值的变量,最后将没重新赋其变量声明改成 const。变量重新赋值的节点如下:

快来享受AST转换的乐趣

接下来使用 jscodeshift 来生成 AST,遍历操作,并重新生成代码。

const j = require('jscodeshift')
const renameVars = []
const code = `
let foo = 2;
let bar = 3;
foo = 3;
console.log(foo, bar)  
`
j(code)
  .find(j.AssignmentExpression)
  .forEach(path => renameVars.push(path.node.left.name))

const ast = j(code)
  .find(j.VariableDeclaration)
  .filter(path => !renameVars.includes(path.node.declarations[0].id.name))
  .forEach(path => {
    path.node.kind = 'const'
  })

console.log(ast.toSource())

代码解析:

  1. 调用 j(code) 生成 AST
  2. 找到所有赋值节点 AssignmentExpression,记下所有赋值操作左侧变量。
  3. 接着重新遍历 AST,找到变量声明 VariableDeclaration 节点,过滤出未被重新赋值的节点,把节点的 kind 值调整为 const
  4. 最后调用 ast.toSource() 重新生成代码,即完成了代码重构。

转换后的代码:

let foo = 2;
const bar = 3;
foo = 3;
console.log(foo, bar)  

React 版本升级中一些生命周期命名调整等,官方就是通过 jscodeshift 来写工具的提供自动化升级,有兴趣的可以更一步去研究。

箭头函数转换

箭头函数转换成普通函数是 babel 中最常见的一个能力,现在我们通过 babel 的相关工具来完成一个简单版本。

babel 的工具链:

工具链的使用可以参考文档或者 Babel Plugin Handbook

先看下转换前 AST:

快来享受AST转换的乐趣

这次要把 ArrowFunctionExpression 节点转换成普通函数节点,接着看下转换后的 AST 结构:

快来享受AST转换的乐趣

const { parse } = require("@babel/parser")
const generate = require("@babel/generator")
const traverse = require("@babel/traverse")
const t = require("@babel/types")

const code = 'const sum = (a, b) => a + b'
const ast = parse(code)

traverse.default(ast, {
  ArrowFunctionExpression(path) {
    const funcExpress = t.functionExpression(
        null,
        path.node.params,
        t.blockStatement([
            t.returnStatement(path.node.body)
        ])
     )
    path.replaceWith(funcExpress)
  }
})

const output = generate.default(ast)

console.log(code)
console.log(output.code)

代码分析:

  1. 首先使用 parse 方法把源码解析成 AST
  2. @babel/traverse 提供了访问者模式遍历,故只需访问 ArrowFunctionExpression
  3. 使用 @babel/types 构建函数表达式,functionExpression 的三个参数为 id,入参,函数体。入参直接使用箭头函数入参 path.node.params,函数体这边构建一个块级声明,里面还需构建返回声明节点,内容就是箭头函数的返回。
  4. 使用 replaceWith 方法替换节点。
  5. 最后使用 @babel/generator 重新生成代码。

tree-shaking

tree-shaking 用于移除未使用到的代码,构建工具像 webpack,vite 都会支持。这个例子带你实现一个简单的 tree-shaking。

需要优化的代码和对应的 AST:

快来享受AST转换的乐趣

函数 subtract 和变量 num3 都未使用到,需要移除掉。实现思路为遍历 AST,记录下表达式中使用到的变量和函数,接着重新遍历 AST,把未使用到的变量和函数移除即可。

const { parse } = require("@babel/parser")
const generate = require("@babel/generator")
const traverse = require("@babel/traverse")
const t = require("@babel//types")

const code = `
const add = (a, b) => a + b;
function subtract(a, b) { return a - b }
const num1 = 1;
const num2 = 10;
const num3 = 100;
add(num1, num2);
`
const ast = parse(code)
const calledDecls = []

traverse.default(ast, {
  ExpressionStatement(path) {
    path.traverse({
      Identifier(path) {
        calledDecls.push(path.node.name)
      }
    })
  }
})

traverse.default(ast, {
  VariableDeclaration(path) {
    for(let declaration of path.node.declarations) {
      if (!calledDecls.includes(declaration.id.name)) {
        path.remove()
      }
    }
  },
  FunctionDeclaration(path) {
    if (!calledDecls.includes(path.node.id.name)) {
      path.remove()
    }
  },
})

const output = generate.default(ast)

console.log(code)
console.log(output.code)

代码分析:

  1. 首先使用 parse 方法把源码解析成 AST
  2. 遍历所有 ExpressionStatement,找到节点下所有的函数和变量名
  3. 重新遍历 AST,访问变量声明和函数声明节点,如果未在表达式中使用到,移除节点。
  4. 最后使用 @babel/generator 重新生成代码。

最后执行结果:

const add = (a, b) => a + b;
const num1 = 1;
const num2 = 10;
add(num1, num2);

babel-import-plugin

这个例子中我们去简单实现下 babel-import-plugin 能力,babel 插件的指南可以看这个文档:Babel Plugin Handbook,核心也是操作 AST。

babel 环境搭建:

  1. 创建文件夹 babel-demo
  2. 安装 babel 包 npm install --save-dev @babel/core @babel/cli
  3. 创建 babel 配置文件 babel.config.json 内容如下
{
  "plugins": [
    "./plugin.js"
  ]
}
  1. 创建 plugin.js 文件,后续实现插件能力
  2. 在 src 目录下创建 index.js,内容如下
import { flatten } from 'lodash'
flatten([1, [2, [3, [4]], 5]])
  1. 最后执行 npx babel src --out-dir lib 即可在 lib 下看的转换的代码。(插件未实现情况下可先去掉配置,不然会报错。因为没有配置 presets,转换代码和源码差不多)

照例先看下转换前后的 AST,转换前如下:

快来享受AST转换的乐趣

转换后如下:

快来享受AST转换的乐趣

可以看到主要是 ImportDeclaration 下的 specifiers 和 source 属性调整。

// plugins
module.exports = function({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path, state) {
        if (t.isImportSpecifier(path.node.specifiers[0])) {
          const declaration = t.importDeclaration(
            [t.importDefaultSpecifier(t.Identifier(path.node.specifiers[0].imported.name))],
            t.StringLiteral(path.node.source.value + '/' + path.node.specifiers[0].imported.name)
          )
          path.replaceWith(declaration)
        }
      }
    }
  }
}

插件也是访问者模式,我们可以传入要访问的节点,同时插件中可以使用 @babel/types 能力。

代码分析:

  1. 在访问器中指定了访问 ImportDeclaration 节点
  2. 判断导入语句是大括号形式导入,例如 import { flatten } from 'lodash',如果是就构建新的导入语句替换它
  3. 构建新的 importDeclaration 节点,第一个参数为导入的标识,第二个参数为引入的库
  4. 第一个参数传入新构建的默认导入节点 importDefaultSpecifier,参数为括号形式导入的方法名;第二个参数直接用原有的库名再拼上方法名。
  5. 替换原节点

上述实现是替换了导入声明节点,你也可以遍历导入声明节点,只修改导入标识和导入库节点。

最后的输出:

import flatten from "lodash/flatten";
flatten([1, [2, [3, [4]], 5]]);

总结

通过以上的学习,相信你对 AST 有了一定认识。AST 的学习必须要自己去实操,才能更快地入门和掌握。除了一些简单转换,后续可以编写 Babel、ESlint 等插件来进一步学习这块知识,当然你也需要学习 JS 是怎么运行起来的,AST 在 JS 运行中处在什么位置。