手把手帶你走進Babel的編譯世界

雲音樂技術團隊發表於2022-03-30
作者:BoBoooooo

前言

談及 Babel,必然離不開 AST。有關 AST 這個知識點其實是很重要的,但由於涉及到程式碼編譯階段,大多情況都是由各個框架內建相關處理,所以作為開發(使用)者本身,往往會忽視這個過程。希望通過這篇文章,帶各位同學走進 AST,藉助 AST 發揮更多的想象力。

AST 概述

想必大家總是聽到 AST 這個概念,那麼到底什麼是 AST?

AST 全稱是是 Abstract Syntax Tree,中文為抽象語法樹,將我們所寫的程式碼轉換為機器能識別的一種樹形結構。其本身是由一堆節點(Node)組成,每個節點都表示原始碼中的一種結構。不同結構用型別(Type)來區分,常見的型別有:Identifier(識別符號),Expression(表示式),VariableDeclaration(變數定義),FunctionDeclaration(函式定義)等。

AST 結構

隨著 JavaScript 的發展,為了統一ECMAScript標準的語法表達。社群中衍生出了ESTree Spec,是目前社群所遵循的一種語法表達標準。

ESTree 提供了例如Identifier、Literal等常見的節點型別。

節點型別

型別說明
File檔案 (頂層節點包含 Program)
Program整個程式節點 (包含 body 屬性代表程式體)
Directive指令 (例如 "use strict")
Comment程式碼註釋
Statement語句 (可獨立執行的語句)
Literal字面量 (基本資料型別、複雜資料型別等值型別)
Identifier識別符號 (變數名、屬性名、函式名、引數名等)
Declaration宣告 (變數宣告、函式宣告、Import、Export 宣告等)
Specifier關鍵字 (ImportSpecifier、ImportDefaultSpecifier、ImportNamespaceSpecifier、ExportSpecifier)
Expression表示式

公共屬性

型別說明
typeAST 節點的型別
start記錄該節點程式碼字串起始下標
end記錄該節點程式碼字串結束下標
loc內含 line、column 屬性,分別記錄開始結束的行列號
leadingComments開始的註釋
innerComments中間的註釋
trailingComments結尾的註釋
extra額外資訊

AST 示例

有的同學可能會問了,這麼多型別都需要記住麼? 其實並不是,我們可以藉助以下兩個工具來查詢 AST 結構。

結合一個示例,帶大家快速瞭解一下 AST 結構。

function test(args) {
  const a = 1;
  console.log(args);
}

上述程式碼,宣告瞭一個函式,名為test,有一個形參args

函式體中:

  • 宣告瞭一個const型別變數a,值為 1
  • 執行了一個 console.log 語句

將上述程式碼貼上至AST Explorer,結果如圖所示:

圖解

接下來我們繼續分析內部結構,以const a = 1為例:

圖解

變數宣告在 AST 中對應的就是 type 為VariableDeclaration的節點。該節點包含kinddeclarations兩個必須屬性,分別代表宣告的變數型別和變數內容。

細心的同學可能發現了declarations是一個陣列。這是為什麼呢?因為變數宣告本身支援const a=1,b=2的寫法,需要支援多個VariableDeclarator,故此處為陣列。

而 type 為VariableDeclarator的節點代表的就是a=1這種宣告語句,其中包含idinit屬性。

id即為Identifier,其中的name值對應的就是變數名稱。

init即為初始值,包含type,value屬性。分別表示初始值型別和初始值。此處 type 為NumberLiteral,表明初始值型別為number型別

Babel 概述

Babel 是一個 JavaScript 編譯器,在實際開發過程中通常藉助Babel來完成相關 AST 的操作。

Babel 工作流程

圖解

Babel AST

Babel 解析程式碼後生成的 AST 是以ESTree作為基礎,並略作修改。

官方原文如下:

The Babel parser generates AST according to Babel AST format. It is based on ESTree spec with the following deviations:

  • Literal token is replaced with StringLiteral, NumericLiteral, BigIntLiteral, BooleanLiteral, NullLiteral, RegExpLiteral
  • Property token is replaced with ObjectProperty and ObjectMethod
  • MethodDefinition is replaced with ClassMethod
  • Program and BlockStatement contain additional directives field with Directive and DirectiveLiteral
  • ClassMethod, ObjectProperty, and ObjectMethod value property's properties in FunctionExpression is coerced/brought into the main method node.
  • ChainExpression is replaced with OptionalMemberExpression and OptionalCallExpression
  • ImportExpression is replaced with a CallExpression whose callee is an Import node.

Babel 核心包

工具包說明
@babel/coreBabel 轉碼的核心包,包括了整個 babel 工作流(已整合@babel/types)
@babel/parser解析器,將程式碼解析為 AST
@babel/traverse遍歷/修改 AST 的工具
@babel/generator生成器,將 AST 還原成程式碼
@babel/types包含手動構建 AST 和檢查 AST 節點型別的方法
@babel/template可將字串程式碼片段轉換為 AST 節點
npm i @babel/parser @babel/traverse @babel/types @babel/generator @babel/template -D

Babel 外掛

Babel 外掛大致分為兩種:語法外掛和轉換外掛。語法外掛作用於 @babel/parser,負責將程式碼解析為抽象語法樹(AST)(官方的語法外掛以 babel-plugin-syntax 開頭);轉換外掛作用於 @babel/core,負責轉換 AST 的形態。絕大多數情況下我們都是在編寫轉換外掛。

Babel 工作依賴外掛。外掛相當於是指令,來告知 Babel 需要做什麼事情。如果沒有外掛,Babel 將原封不動的輸出程式碼。

Babel 外掛本質上就是編寫各種 visitor 去訪問 AST 上的節點,並進行 traverse。當遇到對應型別的節點,visitor 就會做出相應的處理,從而將原本的程式碼 transform 成最終的程式碼。

export default function (babel) {
  // 即@babel/types,用於生成AST節點
  const { types: t } = babel;

  return {
    name: "ast-transform", // not required
    visitor: {
      Identifier(path) {
        path.node.name = path.node.name.split("").reverse().join("");
      },
    },
  };
}

這是一段AST Explorer上的 transform 模板程式碼。上述程式碼的作用即為將輸入程式碼的所有識別符號(Identifier)型別的節點名稱顛倒

其實編寫一個 Babel 外掛很簡單。我們要做的事情就是回傳一個 visitor 物件,定義以Node Type為名稱的函式。該函式接收path,state兩個引數。

其中path(路徑)提供了訪問/操作AST 節點的方法。path 本身表示兩個節點之間連線的物件。例如path.node可以訪問當前節點,path.parent可以訪問父節點等。path.remove()可以移除當前節點。具體 API 見下圖。其他可見
handlebook

圖解

Babel Types

Babel Types 模組是一個用於 AST 節點的 Lodash 式工具庫,它包含了構造、驗證以及變換 AST 節點的方法。

型別判斷

Babel Types 提供了節點型別判斷的方法,每一種型別的節點都有相應的判斷方法。更多見babel-types API

import * as types from "@babel/types";

// 是否為識別符號型別節點
if (types.isIdentifier(node)) {
  // ...
}

// 是否為數字字面量節點
if (types.isNumberLiteral(node)) {
  // ...
}

// 是否為表示式語句節點
if (types.isExpressionStatement(node)) {
  // ...
}

建立節點

Babel Types 同樣提供了各種型別節點的建立方法,詳見下屬示例。

注: Babel Types 生成的 AST 節點需使用@babel/generator轉換後得到相應程式碼。

import * as types from "@babel/types";
import generator from "@babel/generator";

const log = (node: types.Node) => {
  console.log(generator(node).code);
};

log(types.stringLiteral("Hello World")); // output: Hello World

基本資料型別

types.stringLiteral("Hello World"); // string
types.numericLiteral(100); // number
types.booleanLiteral(true); // boolean
types.nullLiteral(); // null
types.identifier(); // undefined
types.regExpLiteral("\\.js?$", "g"); // 正則
"Hello World"
100
true
null
undefined
/\.js?$/g

複雜資料型別

  • 陣列
types.arrayExpression([
  types.stringLiteral("Hello World"),
  types.numericLiteral(100),
  types.booleanLiteral(true),
  types.regExpLiteral("\\.js?$", "g"),
]);
["Hello World", 100, true, /\.js?$/g];
  • 物件
types.objectExpression([
  types.objectProperty(
    types.identifier("key"),
    types.stringLiteral("HelloWorld")
  ),
  types.objectProperty(
    // 字串型別 key
    types.stringLiteral("str"),
    types.arrayExpression([])
  ),
  types.objectProperty(
    types.memberExpression(
      types.identifier("obj"),
      types.identifier("propName")
    ),
    types.booleanLiteral(false),
    // 計算值 key
    true
  ),
]);
{
  key: "HelloWorld",
  "str": [],
  [obj.propName]: false
}

JSX 節點

建立 JSX AST 節點與建立資料型別節點略有不同,此處整理了一份關係圖。

圖解

  • JSXElement

    types.jsxElement(
      types.jsxOpeningElement(types.jsxIdentifier("Button"), []),
      types.jsxClosingElement(types.jsxIdentifier("Button")),
      [types.jsxExpressionContainer(types.identifier("props.name"))]
    );
    <Button>{props.name}</Button>
  • JSXFragment

    types.jsxFragment(types.jsxOpeningFragment(), types.jsxClosingFragment(), [
      types.jsxElement(
        types.jsxOpeningElement(types.jsxIdentifier("Button"), []),
        types.jsxClosingElement(types.jsxIdentifier("Button")),
        [types.jsxExpressionContainer(types.identifier("props.name"))]
      ),
      types.jsxElement(
        types.jsxOpeningElement(types.jsxIdentifier("Button"), []),
        types.jsxClosingElement(types.jsxIdentifier("Button")),
        [types.jsxExpressionContainer(types.identifier("props.age"))]
      ),
    ]);
    <>
      <Button>{props.name}</Button>
      <Button>{props.age}</Button>
    </>

宣告

  • 變數宣告 (variableDeclaration)

    types.variableDeclaration("const", [
      types.variableDeclarator(types.identifier("a"), types.numericLiteral(1)),
    ]);
    const a = 1;
  • 函式宣告 (functionDeclaration)

    types.functionDeclaration(
      types.identifier("test"),
      [types.identifier("params")],
      types.blockStatement([
        types.variableDeclaration("const", [
          types.variableDeclarator(
            types.identifier("a"),
            types.numericLiteral(1)
          ),
        ]),
        types.expressionStatement(
          types.callExpression(types.identifier("console.log"), [
            types.identifier("params"),
          ])
        ),
      ])
    );
    function test(params) {
      const a = 1;
      console.log(params);
    }

React 函式式元件

綜合上述內容,小小實戰一下~

我們需要通過 Babel Types 生成button.js程式碼。乍一看不知從何下手?

// button.js
import React from "react";
import { Button } from "antd";

export default (props) => {
  const handleClick = (ev) => {
    console.log(ev);
  };
  return <Button onClick={handleClick}>{props.name}</Button>;
};

小技巧: 先借助AST Explorer網站,觀察 AST 樹結構。然後通過 Babel Types 逐層編寫程式碼。事半功倍!

types.program([
  types.importDeclaration(
    [types.importDefaultSpecifier(types.identifier("React"))],
    types.stringLiteral("react")
  ),
  types.importDeclaration(
    [
      types.importSpecifier(
        types.identifier("Button"),
        types.identifier("Button")
      ),
    ],
    types.stringLiteral("antd")
  ),
  types.exportDefaultDeclaration(
    types.arrowFunctionExpression(
      [types.identifier("props")],
      types.blockStatement([
        types.variableDeclaration("const", [
          types.variableDeclarator(
            types.identifier("handleClick"),
            types.arrowFunctionExpression(
              [types.identifier("ev")],
              types.blockStatement([
                types.expressionStatement(
                  types.callExpression(types.identifier("console.log"), [
                    types.identifier("ev"),
                  ])
                ),
              ])
            )
          ),
        ]),
        types.returnStatement(
          types.jsxElement(
            types.jsxOpeningElement(types.jsxIdentifier("Button"), [
              types.jsxAttribute(
                types.jsxIdentifier("onClick"),
                types.jSXExpressionContainer(types.identifier("handleClick"))
              ),
            ]),
            types.jsxClosingElement(types.jsxIdentifier("Button")),
            [types.jsxExpressionContainer(types.identifier("props.name"))],
            false
          )
        ),
      ])
    )
  ),
]);

應用場景

AST 本身應用非常廣泛,例如:Babel 外掛(ES6 轉化 ES5)、構建時壓縮程式碼 、css 前處理器編譯、 webpack 外掛等等,可以說是無處不在。

圖解

如圖所示,不難發現,一旦涉及到編譯,或者說程式碼本身的處理,都和 AST 息息相關。下面列舉了一些常見應用,讓我們看看是如何處理的。

程式碼轉換

// ES6 => ES5 let 轉 var
export default function (babel) {
  const { types: t } = babel;

  return {
    name: "let-to-var",
    visitor: {
      VariableDeclaration(path) {
        if (path.node.kind === "let") {
          path.node.kind = "var";
        }
      },
    },
  };
}

babel-plugin-import

在 CommonJS 規範下,當我們需要按需引入antd的時候,通常會藉助該外掛。

該外掛的作用如下:

// 通過es規範,具名引入Button元件
import { Button } from "antd";
ReactDOM.render(<Button>xxxx</Button>);

// babel編譯階段轉化為require實現按需引入
var _button = require("antd/lib/button");
ReactDOM.render(<_button>xxxx</_button>);

簡單分析一下,核心處理: 將 import 語句替換為對應的 require 語句。

export default function (babel) {
  const { types: t } = babel;

  return {
    name: "import-to-require",
    visitor: {
      ImportDeclaration(path) {
        if (path.node.source.value === "antd") {
          // var _button = require("antd/lib/button");
          const _botton = t.variableDeclaration("var", [
            t.variableDeclarator(
              t.identifier("_button"),
              t.callExpression(t.identifier("require"), [
                t.stringLiteral("antd/lib/button"),
              ])
            ),
          ]);
          // 替換當前import語句
          path.replaceWith(_botton);
        }
      },
    },
  };
}

TIPS: 目前 antd 包中已包含esm規範檔案,可以依賴 webpack 原生 TreeShaking 實現按需引入。

LowCode 視覺化編碼

當下LowCode,依舊是前端一大熱門領域。目前主流的做法大致下述兩種。

  • Schema 驅動

    目前主流做法,將表單或者表格的配置,描述為一份 Schema,視覺化設計器基於 Schema 驅動,結合拖拽能力,快速搭建。

  • AST 驅動

    通過CloudIDECodeSandbox等瀏覽器端線上編譯,編碼。外加視覺化設計器,最終實現視覺化編碼。

圖解

大致流程如上圖所示,既然涉及到程式碼修改,離不開AST的操作,那麼又可以發揮 babel 的能力了。

假設設計器的初始程式碼如下:

import React from "react";

export default () => {
  return <Container></Container>;
};

此時我們拖拽了一個Button至設計器中,根據上圖的流程,核心的 AST 修改過程如下:

  • 新增 import 宣告語句 import { Button } from "antd";
  • <Button></Button>插入至<Container></Container>

話不多說,直接上程式碼:

import traverse from "@babel/traverse";
import generator from "@babel/generator";
import * as parser from "@babel/parser";
import * as t from "@babel/types";

// 原始碼
const code = `
  import React from "react";

  export default () => {
    return <Container></Container>;
  };
`;

const ast = parser.parse(code, {
  sourceType: "module",
  plugins: ["jsx"],
});

traverse(ast, {
  // 1. 程式頂層 新增import語句
  Program(path) {
    path.node.body.unshift(
      t.importDeclaration(
        // importSpecifier表示具名匯入,相應的匿名匯入為ImportDefaultSpecifier
        // 具名匯入對應程式碼為 import { Button as Button } from 'antd'
        // 如果相同會自動合併為 import { Button } from 'antd'
        [t.importSpecifier(t.identifier("Button"), t.identifier("Button"))],
        t.stringLiteral("antd")
      )
    );
  },
  // 訪問JSX節點,插入Button
  JSXElement(path) {
    if (path.node.openingElement.name.name === "Container") {
      path.node.children.push(
        t.jsxElement(
          t.jsxOpeningElement(t.jsxIdentifier("Button"), []),
          t.jsxClosingElement(t.jsxIdentifier("Button")),
          [t.jsxText("按鈕")],
          false
        )
      );
    }
  },
});

const newCode = generator(ast).code;
console.log(newCode);

結果如下:

import { Button } from "antd";
import React from "react";
export default () => {
  return (
    <Container>
      <Button>按鈕</Button>
    </Container>
  );
};

ESLint

自定義 eslint-rule,本質上也是訪問 AST 節點,是不是跟 Babel 外掛的寫法很相似呢?

module.exports.rules = {
  "var-length": (context) => ({
    VariableDeclarator: (node) => {
      if (node.id.name.length <= 2) {
        context.report(node, "變數名長度需要大於2");
      }
    },
  }),
};

Code2Code

以 Vue To React 為例,大致過程跟ES6 => ES5類似,通過vue-template-compiler編譯得到 Vue AST => 轉換為 React AST => 輸出 React 程式碼

有興趣的同學可以參考vue-to-react

其他多端框架:一份程式碼 => 多端,大體思路一致。

總結

在實際開發中,遇到的情況往往更加複雜,建議大家多番文件,多觀察,用心去感受 ~

參考文章

  1. babel-handlebook
  2. @babel/types
  3. [透過製作 Babel-plugin 初訪 AST
    ](https://blog.techbridge.cc/20...)
  4. [@babel/types 深度應用
    ](https://juejin.cn/post/698494...)
本文釋出自網易雲音樂技術團隊,文章未經授權禁止任何形式的轉載。我們常年招收各類技術崗位,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!

相關文章