玩转ast- 手写babel插件篇

639次阅读  |  发布于1年以前

AST

抽象语法树是什么?

以下是普通函数function ast(){},转换为ast后的格式:

抽象语法树用途有哪些?

webpack、Lint等这些工具的原理都是通过 JavaScript Parser 把源代码转化为一颗抽象语法树(AST),通过操纵这颗树,我们可以精准的定位到声明语句、赋值语句、运算语句等等,实现对代码的分析、优化、变更等操作。

JavaScript解析器

那什么是JavaScript解析器呢? JavaScript解析器的作用,把JavaScript源码转化为抽象语法树。 浏览器会把js源码通过解析器转换为ast,再进一步转换为字节码或者直接生成机器码,进行渲染和执行。 一般来说,每个js引擎都有自己的抽象语法树格式,Chrome的v8引擎, firfox的Spider Monkey引擎等。

JavaScript解析器通常可以包含四个组成部分。

词法分析器(Lexical Analyser)

首先词法分析器会扫描(scanning)代码,将一行行源代码,通过switch case 把源码?“/为一个个小单元,jS代码有哪些语法单元呢?大致有以下这些:

就是一个字符一个字符地遍历,然后通过switch case分情况讨论,整个实现方法就是顺序遍历和大量的条件判断。以@babel/parser源码为例:

getTokenFromCode(code) {
  switch (code) {
    case 46:
      this.readToken_dot();
      return;

    case 40:
      ++this.state.pos;
      this.finishToken(10);
      return;

    case 41:
      ++this.state.pos;
      this.finishToken(11);
      return;

    case 59:
      ++this.state.pos;
      this.finishToken(13);
      return;

      // 此处省略...

    case 92:
      this.readWord();
      return;

    default:
      if (isIdentifierStart(code)) {
        this.readWord(code);
        return;
      }

  }

}

readToken_dot() {
  // charCodeAt一个一个向后移
  const next = this.input.charCodeAt(this.state.pos + 1);

  if (next >= 48 && next <= 57) {
    this.readNumber(true);
    return;
  }

  if (next === 46 && this.input.charCodeAt(this.state.pos + 2) === 46) {
    this.state.pos += 3;
    this.finishToken(21);
  } else {
    ++this.state.pos;
    this.finishToken(16);
  }
}

语法解析器

将上一步生成的数组,根据语法规则,转为抽象语法树(Abstract Syntax Tree,简称AST)。 以 const a = 1 为例,词法分析中获得了 const 这样一个 token,并判断这是一个关键字,根据这个 token 的类型,判断这是一个变量声明语句。以@babel/parser源码为例,执行 parseVarStatement 方法。

parseVarStatement(node, kind, allowMissingInitializer = false) {
    const {
      isAmbientContext
    } = this.state;
    const declaration = super.parseVarStatement(node, kind, allowMissingInitializer || isAmbientContext);
    if (!isAmbientContext) return declaration;


    for (const {
      id,
      init
    } of declaration.declarations) {
      if (!init) continue;


      if (kind !== "const" || !!id.typeAnnotation) {
        this.raise(TSErrors.InitializerNotAllowedInAmbientContext, {
          at: init
        });
      } else if (init.type !== "StringLiteral" && init.type !== "BooleanLiteral" && init.type !== "NumericLiteral" && init.type !== "BigIntLiteral" && (init.type !== "TemplateLiteral" || init.expressions.length > 0) && !isPossiblyLiteralEnum(init)) {
        this.raise(TSErrors.ConstInitiailizerMustBeStringOrNumericLiteralOrLiteralEnumReference, {
          at: init
        });
      }
    }


    return declaration;
  }

经过这一步的处理,最终 const a = 1 会变成如下ast结构:

字节码生成器

字节码生成器的作用,是将抽象语法树转为JavaScript引擎可以执行的二进制代码。目前,还没有统一的JavaScript字节码的格式标准,每种JavaScript引擎都有自己的字节码格式。最简单的做法,就是将语义单位翻成对应的二进制命令。

字节码解释器

字节码解释器的作用是读取并执行字节码。

几种常见的解析器

Esprima

这是第一个用JavaScript编写的符合EsTree规范的JavaScript的解析器,后续多个编译器都是受它的影响

acorn

acorn 和 Esprima 很类似,输出的ast都是符合 EsTree 规范的,目前webpack的AST解析器用的就是acorn

@babel/parser(babylon)

babel官方的解析器,最初fork于acorn,后来完全走向了自己的道路,从babylon改名之后,其构建的插件体系非常强大

uglify-js

用于混淆和压缩代码,因为一些原因,uglify-js自己[内部实现了一套AST规范,也正是因为它的AST是自创的,不是标准的ESTree,es6以后新语法的AST,都不支持,所以没有办法压缩最新的es6的代码,如果需要压缩,可以用类似babel这样的工具先转换成ES5。

esbuild

esbuild是用go编写的下一代web打包工具,它拥有目前最快的打包记录和压缩记录,snowpack和vite的也是使用它来做打包工具,为了追求卓越的性能,目前没有将AST进行暴露,也无法修改AST,无法用作解析对应的JavaScript。

babel

下面先介绍下babel相关工具库,以及一些API,了解完这些基础概念后,我们会利用这些工具操作AST来编写一个babel插件。

Visitor

path

scope

scope.bindings 当前作用域内声明所有变量 • scope.path 生成作用域的节点对应的路径 • scope.references 所有的变量引用的路径 • getAllBindings() 获取从当前作用域一直到根作用域的集合 • getBinding(name) 从当前作用域到根使用域查找变量 • getOwnBinding(name) 在当前作用域查找变量 • parentHasBinding(name, noGlobals) 从当前父作用域到根使用域查找变量 • removeBinding(name) 删除变量 • hasBinding(name, noGlobals) 判断是否包含变量 • moveBindingTo(name, scope) 把当前作用域的变量移动到其它作用域中 • generateUid(name) 生成作用域中的唯一变量名,如果变量名被占用就在前面加下划线 • scope.dump() 打印自底向上的 作用域与变量信息到控制台

实现eslint插件

上面这些概念在我们编写babel插件时会用到,接下来我们来实现一个eslint移除console.log()插件吧! 先看下 console.log('a') 的AST长什么样子?

可以看到 console.log('a') 是一个type为 “ExpressionStatement”的节点,所以我们只需要遍历AST,当遇到type=ExpressionStatement的节点删除即可!来一起实现吧~

1 . 引入基础包

var fs = require("fs");
//babel核心模块,里面包含transform方法用来转换源代码。
const core = require('@babel/core');
//用来生成或者判断节点的AST语法树的节点
let types = require("@babel/types");

2. 遍历AST节点,babel插件的语法都是固定的,里面包含visitor,我们只需在visitor里面处理ExpressionStatement即可,

//no-console 禁用 console
const eslintPlugin = ({ fix }) => {
  // babel插件的语法都是固定的,里面包含visitor
    return {
      pre(file) {
        file.set('errors', []);
      },
      // 访问器
      visitor: {
        CallExpression(path, state) {
          const errors = state.file.get('errors');
          const { node } = path
          if (node.callee.object && node.callee.object.name === 'console') {
            errors.push(path.buildCodeFrameError(`代码中不能出现console语句`, Error));
            if (fix) {
              path.parentPath.remove();
            }
          }
        }
      },
      post(file) {
        // console.log(...file.get('errors'));
      }
    }
  };

3 . 完整实现:

var fs = require("fs");
//babel核心模块,里面包含transform方法用来转换源代码。
const core = require('@babel/core');
//用来生成或者判断节点的AST语法树的节点
let types = require("@babel/types");

//no-console 禁用 console
const eslintPlugin = ({ fix }) => {
  // babel插件的语法都是固定的,里面包含visitor
    return {
      pre(file) {
        file.set('errors', []);
      },
      // 访问器
      visitor: {
        CallExpression(path, state) {
          const errors = state.file.get('errors');
          const { node } = path
          if (node.callee.object && node.callee.object.name === 'console') {
            errors.push(path.buildCodeFrameError(`代码中不能出现console语句`, Error));
            if (fix) {
              path.parentPath.remove();
            }
          }
        }
      },
      post(file) {
        // console.log(...file.get('errors'));
      }
    }
  };

core.transformFile("eslint/source.js",{
    plugins: [eslintPlugin({ fix: false })]
}, function(err, result) {
    result; // => { code, map, ast }
    console.log(result.code);
  })

- END -

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8