玩轉 ast- 手寫 babel 插件篇

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");
  1. 遍歷 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'));
      }
    }
  };
  1. 完整實現:
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);
  })
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_j2lcoNK7XIsAJbmddFMtA