绝密笔记 | 掌握 AST,轻松落地关键业务「技术创作101训练营」

如果你查看目前任何主流的项目中的 devDependencies,我们不会在生产环境用到,但是它们在开发过程中充当着重要的角色。归纳一下有:javascript转译、代码压缩、css预处理器、elint、pretiier,postcss等。所有的上述工具,不管怎样,都建立在了AST这个巨人的肩膀上,都是 AST 的运用:

  • 编辑器的错误提示、代码格式化、代码高亮、代码自动补全;
  • elint、pretiier 对代码错误或风格的检查;
  • webpack 通过 babel 转译 javascript 语法;

同时,在业务使用 AST 可以解决一些通过常规方式处理很繁琐的问题。如通过 AST 解决识别 slot 插槽名称、$emit 事件名称、解析模板字符串并进行替换等关键逻辑问题(这些都已在我们的项目中落地)。

AST 是什么

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

AST 可以将代码转换成 JSON 语法树,基于语法树可以进行代码转换、替换等很多操作,其实AST应用非常广泛,我们开发当中使用的 less/sass、eslint、TypeScript 等很多插件都是基于 AST 实现的。

通过利用 AST 技术,不仅仅是上述的功能,在现在开发模式中,也诞生了各种各样的工具和框架和插件,很多底层多少都能看到 AST 的影子,比方说之前比较流行的 vue 转小程序,就是通过将 vue 的语法树,解析成小程序的语法树,然后在小程序上运行的。当然这样的例子还有很多…

Babel 中的 AST

Babel 能够转译 ECMAScript 2015+ 的代码,使它在旧的浏览器或者环境中也能够运行。可以尝试一下: https://babel.docschina.org/repl

AST

Babel 是一个编译器,大多数编译器的工作过程可以分为三部分:

  1. 「Parse(解析)」:将源代码转换成更加抽象的表示方法(如抽象语法树
  2. 「Transform(转换)」:对(抽象语法树)做一些特殊处理,让它符合编译器的期望
  3. 「Generate(代码生成)」:将第二步经过转换过的(抽象语法树)生成新的代码

结合上述编译过程,找到对应的 Babel 插件:

  • @babel/core:用来解析 AST 以及将 AST 生成代码
    解析 Parse(@babel/parser) ==> 转换 Transform(@babel/traverse@babel/types) ==> 生成 Generate(@babel/generator

    • 解析:产物为 AST,分为词法分析和语法分析两个阶段;
    • 转换:将获取AST并遍历它,并进行添加,更新和删除节点;
    • 生成:获取最终的AST,并将其返回为一串代码
  • @babel/types:构建新的 AST 节点

示例:

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

const { parse } = require('@babel/core')let ast = parse(`
function add (a, b) {
  return a + b
}`)

javascript 对象结构:

{
  "type": "Program",
  "body": [{
    "type": "FunctionDeclaration"
  }]
}

AST树层级关系及 types 标识「astexplorer可查看」:

- FunctionDeclaration:
  - id:
    - Identifier:
      - name: add
  - params [2]
    - Identifier
      - name: a
    - Identifier
      - name: a
  - body:
    - BlockStatement
      - body [1]
        - ReturnStatement
          - argument
            - BinaryExpression
              - operator: +
              - left
                - Identifier
                  - name: a
              - right
                - Identifier
                  - name: b

Traversal/Visitors

https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#toc-babel-traverse

Visitors 是跨语言的 AST 遍历中使用的一种模式。简而言之,它们是一个对象,接收特定节点类型的方法。

const MyVisitor = {
  // 树中的每个 Identifier 调用 Identifier() 方法
  Identifier() {
    console.log("Called!");
  }
}

Note: Identifier() { ... } is shorthand for Identifier: { enter() { ... } }.

traverse(ast, {
  ...MyVisitor
})

上述 Identifier() 会被调用 5 次!

- FunctionDeclaration
  - Identifier (id)
  - Identifier (params[0])
  - Identifier (params[1])
  - BlockStatement (body)
    - ReturnStatement (body)
      - BinaryExpression (argument)
        - Identifier (left)
        - Identifier (right)

遍历树的每个分支,直到该分支结束;然后达到下一个节点。

  • Enter FunctionDeclaration
    • Enter Identifier (id)
      • Hit dead end
    • Exit Identifier (id)
    • Enter Identifier (params[0])
      • Hit dead end
    • Exit Identifier (params[0])
    • Enter Identifier (params[1])
      • Hit dead end
    • Exit Identifier (params[1])
    • Enter BlockStatement (body)
      • Enter ReturnStatement (body)
        • Enter BinaryExpression (argument)
          • Enter Identifier (left)
          • Hit dead end
          • Exit Identifier (left)
          • Enter Identifier (right)
          • Hit dead end
          • Exit Identifier (right)
        • Exit BinaryExpression (argument)
      • Exit ReturnStatement (body)
    • Exit BlockStatement (body)
  • Exit FunctionDeclaration

通过上述可知,有两次访问节点的机会。

const MyVisitor = {
  Identifier: {
    enter() {
      console.log("Entered!");
    },
    exit() {
      console.log("Exited!");
    }
  }
}

Traversal/Paths

Paths 是两个节点 Node 之间链接的对象表示。

{
  "parent": {...},
  "node": {...},
  "hub": {...},
  "contexts": [],
  "data": {},
  "shouldSkip": false,
  "shouldStop": false,
  "removed": false,
  "state": null,
  "opts": null,
  "skipKeys": null,
  "parentPath": null,
  "context": null,
  "container": null,
  "listKey": null,
  "inList": false,
  "parentKey": null,
  "key": null,
  "scope": null,
  "type": null,
  "typeAnnotation": null
}

同时,也包含 添加,更新,移动、删除 节点相关的方法。

Babel AST 相关 Api

babel-parser

将 Javascript 代码转换为 AST

方法

说明

parse(code, {sourceType: ‘module|script’, plugins: [‘jsx’]})

https://babeljs.io/docs/en/babel-parser

babel-traverse

遍历 AST 树,并负责替换,删除和添加节点。

方法

说明

traverse(ast, {XDeclaration (path) {}})

https://babeljs.io/docs/en/babel-traverse

babel-types

构建,验证和转换AST节点的方法。https://babeljs.io/docs/en/babel-traverse

方法

说明

builder: [“operator”, “left”, “right”]

t.binaryExpression(“*”, t.identifier(“a”), t.identifier(“b”));

t.isBinaryExpression(maybeBinaryExpressionNode)

验证

babel-generator

AST 生成 Javascript 代码

方法

说明

generate(ast, { /* options */ }, code)

https://babeljs.io/docs/en/babel-generator

visitor

https://github.com/jamiebuilds/babel-handbook/blob/master/translations/en/plugin-handbook.md#transformation-operations

  • path.get("declaration.body.body.0")
  • path.isReferencedIdentifier()
  • path.findParent((path) => path.isObjectExpression())
  • path.find((path) => path.isObjectExpression())
  • path.getFunctionParent()
  • path.getSibling(0)
  • path.skip()

manipulation

  • path.replaceWith(t.binaryExpression("**", path.node.left, t.numberLiteral(2)))
  • path.replaceWithMultiple([])
  • path.replaceWithSourceString()
  • path.insertBefore/insertAfter()
  • path.unshiftContainer/pushContainer()
  • path.remove()

scope

javascript 词法作用域。当创建引用时,无论是 variable, function, class, param, import, label 等,其都属于当前 scope。

// global scopefunction scopeOne() {
  // scope 1function scopeTwo() {
    // scope 2
  }
}

  • 对于 deeper scope(如 scope2),可以使用 higher scope(scope1/scope)
  • lower scope 可以创建相同的 name(scope2 中可以创建与 scope1 中已有的变量)
function scopeOne() {
  var one = "I am in the scope created by `scopeOne()`";
  var two = "I am in the scope created by `scopeOne()`";function scopeTwo() {
    // 使用 higher scope 的变量 one
    one = "I am updating the reference in `scopeOne` inside `scopeTwo`";
    // 创建相同名称的变量 two
    var two = "I am creating a new `two` but leaving reference in `scopeOne()` alone.";
  }
} 

相关 API:

  • 检查是否绑定了变量:path.scope.hasBinding("n")path.scope.hasOwnBinding("n")
  • 重命名绑定:path.scope.rename("n", "x")
  • 添加变量到父作用域:path.scope.parent.push({ id, init: path.node })

AST 示例

创建或修改节点时,可通过 https://www.babeljs.cn/docs/babel-types 进行查看相关方法!!!

示例1:生成指定代码,通过 babel 生成下述代码

代码地址:https://github.com/381510688/practice/blob/master/ast/code.js

async function test() {
  let res = await Promise.resolve(123);
  console.log(res);
}

const { transformFromAstSync, types: t } = require("@babel/core")// let res = await Promise.reslove('123')
const resVar = t.VariableDeclaration('let', [
  t.VariableDeclarator(
    t.Identifier('res'),
    t.AwaitExpression(t.CallExpression(
      t.MemberExpression(t.Identifier('Promise'), t.Identifier('resolve')),
      [t.numericLiteral(123)]
    ))
  )
])// console.log(res)
const consoleExpress = t.ExpressionStatement(t.CallExpression(
  t.MemberExpression(t.Identifier('console'), t.Identifier('log')),
  [t.Identifier('res')]
))let ast = t.functionDeclaration(
  t.Identifier('test'),
  [],
  t.BlockStatement([
    resVar,
    consoleExpress
  ]),
  false,
  true
)let code = transformFromAstSync(t.program([ast]), null, {}).code
console.log(code)

示例2:在某一类别下查找指定类别

在 name 为 ”o“ 的 Identifier 类型下,查找 StringLiteral 类型!

babel.traverse(ast, {
  enter (path) {
    if (path.isIdentifier({ name: 'o' })) {
      path.parentPath.traverse({
        StringLiteral (path) {
          cosnt svgHtml = path.node.value
        }
      })
    }
  }
}

示例3:对函数进行包裹 try…catch

代码地址:https://github.com/381510688/practice/blob/master/ast/try-wrapper.js

const fs = require('fs')
const { transformFromAstSync, parse, traverse, types: t } = require('@babel/core')
const { SSL_OP_NO_SESSION_RESUMPTION_ON_RENEGOTIATION } = require('constants')let code = fs.readFileSync('./1.js', { encoding: 'utf-8' })
let ast = parse(code)// 判断async函数
const isAsyncFuncNode = node => {
  return t.isFunctionDeclaration(node, { async: true })
    || t.isArrowFunctionExpression(node, { async: true })
    || t.isFunctionExpression(node, { async: true })
    || t.isObjectMethod(node, { async: true })
}traverse(ast, {
  AwaitExpression(path) {
    // 递归向上找异步函数的 node 节点
    while (path && path.node) {
      let parentPath = path.parentPath
      // 已经包含 try 语句则直接退出
      if (t.isBlockStatement(path.node) && t.isTryStatement(parentPath.node)) {
        return 
      }// 确认 async function
      if (t.isBlockStatement(path.node) && isAsyncFuncNode(parentPath.node)) {
        // 创建 tryStatement https://www.babeljs.cn/docs/babel-types#trystatement
        let tryCatchAst = t.tryStatement(
          path.node,
          t.catchClause(t.Identifier('e'), t.BlockStatement(parse(`console.error(e)`).program.body)),
          null
        )
        path.replaceWithMultiple([tryCatchAst])  
        return 
      }
      path = parentPath // 递归
    }
  }
})let newCode = transformFromAstSync(ast, null, {}).code
console.log(newCode)

更加完整示例参考:https://github.com/yeyan1996/async-catch-loader/blob/master/src/index.js

最佳实践

  1. 可以将多个 visitor 用 | 关联,已达到相同操作
    const MyVisitor = { “Identifier|FunctionDeclaration” () {} }
  2. 巧用 alias, 如 Function 是 FunctionDeclaration, FunctionExpression, ArrowFunctionExpression, ObjectMethod and ClassMethod 别名
    const MyVisitor = { Function (path) {} }
  3. 对于固定代码替换 ast 节点,无需手动通过函数进行拼装,可以通过 parse 进行快速生成,如上述的 console.error('e')
    // 简便写法 t.BlockStatement(parse(`console.error(e)`).program.body) ​ // 传统写法 t.BlockStatement([ t.ExpressionStatement( t.CallExpression( t.MemberExpression(t.Identifier(‘console’), t.Identifier(‘error’)), [t.Identifier(‘e’)] ) ) ])

AST 延展

javascript 是解释型语言还是编译型语言?

对于了解 javascript 语言的小伙伴,肯定都可以确认的给出答案:解释型语言!!!

提出这个问题主要源于两点:

  1. 如果 JS 是解释型语言那为什么会有变量提升(hoisting)
  2. JIT(即时编译)会做代码优化(同时创建代码的编译版本);解释型语言无法做到这些啊?!

名称

说明

解释型语言

程序不需要编译,程序在运行时才翻译成机器语言,每执行一次都要翻译一次(一行行地边解释边执行)

编译型语言

程序在执行之前需要把程序编译成为机器语言的文件,运行时不需要重新翻译,直接使用编译的结果就行了

解释器:

  • 利:启动和执行的更快,从第一行开始翻译,就可以依次继续执行了
  • 弊: 运行同样的代码一次以上(如执行一个循环),解释器就不得不一次又一次的进行翻译,这是一种效率低下的表现

编译器:

  • 利:循环的代码执行的很快,因为它不需要重复的去翻译每一次循环;同时,可以用更多的时间对代码进行优化,以使的代码执行的更快
  • 弊: 需要花一些时间对整个源代码进行编译,然后生成目标文件才能在机器上执行
javascript 变量提升

在函数作用域内的任何变量的声明都会被提升到顶部并且值为 undeinfed

上述是如何做到的?解释了两次?还是先编译后运行?

  • 一旦 V8 引擎进入一个执行具体代码的执行上下文(函数),它就对代码进行词法分析或者分词。这意味着代码将被分割成像foo = 10这样的原子符号(atomic token)。
  • 在对当前的整个作用域分析完成后,引擎将 token 解析翻译成一个AST(抽象语法树)。
  • 引擎每次遇到声明语句,就会把声明传到作用域(scope)中创建一个绑定「见下述 scope」。每次声明都会为变量分配内存。只是分配内存,并不会修改源代码将变量声明语句提升。正如你所知道的,在JS中分配内存意味着将变量默认设为undefined
  • 在这之后,引擎每一次遇到赋值或者取值,都会通过作用域(scope)查找绑定。如果在当前作用域中没有查找到就接着向上级作用域查找直到找到为止。
  • 接着引擎生成 CPU 可以执行的机器码。
  • 最后, 代码执行完毕。

所以,变量提升不过是执行上下文的小把戏

即时编译 JIT(Just-in-time)

整体来说,为了解决解释器的低效问题,后来的浏览器把编译器也引入进来,形成混合模式。最终,结合了解释器和编译器的两者优点。

基本思想: 在 JavaScript 引擎中增加一个监视器(也叫分析器)。监视器监控着代码的运行情况,记录代码一共运行了多少次、如何运行的等信息。如果同一行代码运行了几次,这个代码段就被标记成了 “warm”,如果运行了很多次,则被标记成 “hot”。

  • 如果一段代码变成了 “warm”,就把它送到基线编译器中,并且把编译结果存储起来;后续监视器监视到了执行同样的代码和同样的变量类型,那么就直接把这个已编译的版本 push 出来给浏览器。
  • 如果一段代码变成了 “hot”,就把它发送到优化编译器中。生成一个更快速和高效的代码版本出来,并且存储。为了生成一个更快速的代码版本,优化编译器必须做一些假设。编译代码需要在运行之前检查其假设是不是合理的。如果合理,那么优化的编译代码会运行,如果不合理,那么 JIT 会认为做了一个错误的假设,并且把优化代码丢掉。当发生优化代码丢弃的情况,执行过程将会回到解释器或者基线编译器,这一过程叫做去优化

JIT 会增加很多多余的开销:

  • 优化和去优化开销
  • 监视器记录信息对内存的开销
  • 发生去优化情况时恢复信息的记录对内存的开销
  • 对基线版本和优化后版本记录的内存开销

通过消除开销使得性能上有进一步地提升,这也是 WebAssembly 所要做的事之一。

Parser

Supported Languages

Github

acorn

esnext & JSX (using acorn-jsx)

https://github.com/acornjs/acorn

Traversing

Github

recast

https://github.com/benjamn/recast

总结

AST 并不是一门技术,更多的是偏向于编程的思想,理解原理以及思路,可以提供更多实现的可能。其他语言同样存在类似的 Parser,如下:

语言

Parser

javascript

acorn

vue

@vue/compiler-core

css

cssom

json

jsonToAst

等等,可以通过 https://astexplorer.net/ 来选择不同的语言。

参考链接

正文完