動態

詳情 返回 返回

手把手帶你走進Babel的編譯世界 - 動態 詳情

作者: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 表達式

公共屬性

類型 説明
type AST 節點的類型
start 記錄該節點代碼字符串起始下標
end 記錄該節點代碼字符串結束下標
loc 內含 line、column 屬性,分別記錄開始結束的行列號
leadingComments 開始的註釋
innerComments 中間的註釋
trailingComments 結尾的註釋
extra 額外信息

AST 示例

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

  • AST Explorer (常用)
    圖解
  • 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/core Babel 轉碼的核心包,包括了整個 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!
user avatar tianmiaogongzuoshi_5ca47d59bef41 頭像 cyzf 頭像 zaotalk 頭像 nihaojob 頭像 freeman_tian 頭像 jingdongkeji 頭像 qingzhan 頭像 kobe_fans_zxc 頭像 dirackeeko 頭像 aqiongbei 頭像 chongdianqishi 頭像 leexiaohui1997 頭像
點贊 86 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.