動態

詳情 返回 返回

AST,真香 - 動態 詳情

豆皮粉兒們,又見面了,今天這一期,由字節跳動數據平台的太郎醬,帶大家走進AST的世界。

作者:太郎醬

什麼是AST

抽象語法樹(Abstract Syntax Tree, AST),是源代碼的抽象語法結構的樹狀表示,與之對應的是具體語法樹;之所以是抽象的,是因為抽象語法樹並不會表示出真實語法中出現的每一個細節,而且是文法無關、不依賴於語言的細節;可以把AST想象成一套標準化的編程語言接口定義,只不過這一套規範,是針對編程語言本身的,小到變量聲明,大到複雜模塊,都可以用這一套規範描述,有興趣的同學可以深入瞭解AST的概念和原理,本文的重點聚焦在JavaScript AST的應用。

為什麼要談AST

對於前端同學來説,日常開發中,和AST有關的場景無處不在;比如:webpack、babel、各種lint、prettier、codemod 等,都是基於AST處理的;掌握了AST,相當於掌握了控制代碼的代碼能力,可以幫助我們拓寬思路和視野,不管是寫框架,還是寫工具和邏輯,AST都會成為你的得力助手。

AST解析流程

先推薦一個AST在線轉換網站: astexplorer.net , 收藏它,很重要;除了js,還有很多其他語言的AST庫;不用做任何配置,就可以作為一個playground;

在講解case之前,先了解下解析流程,分為三步:

  1. source code --> ast (源代碼解析為ast)
  2. traverse ast (遍歷ast,訪問樹中的各個節點,對節點做各種操作)
  3. ast --> code (把ast轉換為源碼,打完收工)

源碼解析成為AST的引擎有很多,轉換出來的AST大同小異;

Use Cases

從一個變量聲明説起,如下:

const dpf = 'DouPiFan';

把代碼複製到astexplorer中,得到如下結果(結果已簡化),這張圖解釋了從源碼到AST的過程;

選擇不同的第三方庫來生成AST,結果會有所差異,這裏以babel/parse為例;前端同學對babel再熟悉不過了,通過它的處理,可以在瀏覽器中支持ES2015+的代碼,這僅僅是babel的其中一個應用場景,官方對自己的定位是:Babel is a javascript compiler。

回到 babel-parser,它使用 Babylon 作為解析引擎,它是AST 到 AST 的操作,babel在Babylon的基礎上,封裝瞭解析(babel-parser)和生成(babel-generator)這兩步,因為每次操作都會做這兩步;對於應用而言,操作的重點就是AST節點的遍歷和更新了;

第一個babel插件

我們以一個最簡單的babel插件為例,來了解它的處理過程;

當我們開發babel-plugin的時候,我們只需要在 visitor 中描述如何進行AST的轉換即可。把它加入你的babel插件列表,就可以工作了,我們的第一個babel插件開發完成;

babel-plugin-import是如何實現的?

使用過antd的同學,都知道 babel-plugin-import插件,它是用來做antd組件的按需加載,配置之後的效果如下:

import { Button } from 'antd'

    ↓ ↓ ↓ ↓ ↓ ↓

import Button from 'antd/lib/button'
本文旨在拋磚引玉,對於插件的實現細節以及各種邊界條件,可參考插件源碼;

以AST的思維來思考,實現步驟如下:

  1. 查找代碼中的 import 語句,且必須是 import { xxx } from 'antd'
  2. 把步驟一找到的節點,轉換為 import Button from 'antd/lib/button'

實現步驟

  1. 打開神器: AST Explorer,把第一行代碼複製到神器中
  2. 點擊代碼中的 import 關鍵字,會自動定位到對應的節點,結構如下:
ImportDeclaration {

    type: "ImportDeclaration",

    specifiers: [{  // 對應 {} 括號中的組件

        ImportSpecifier: {

            type: "ImportSpecifier",

            imported: {

                type: "Identifier",

                name: "Button"

            }

        }

    }] 

    source: {

        type: "StringLiteral",

        value: "antd"

    },

    ...

}

源碼被轉換成帶有類型和屬性的對象,不管是關鍵字、變量聲明,還是字面量值,都有對應類型;

  1. import 語句對應的類型是: ImportDeclaration
  2. { Button }對應的是 specifiers 數組,示例中只引入了 "Button",所以specifiers數組中的元素只有一個
  3. specifiers中的元素,也就是 Button,類型是 ImportSpecifier;
  4. 'antd' 在 source 節點中,類型是:StringLiteral,value為antd
再次説明:示例並非完整邏輯實現,細節和邊界條件,可參考源碼或自己完善;

針對AST的操作,和瀏覽器自帶DOM API 類似;先確定要查找節點的類型,然後根據具體的條件,縮小搜索範圍,最後對查找到的節點,進行增刪改查;

// babel插件模板

export default function({types: t}) {

    return {

        // Visitor 中的每個函數接收2個參數:path 和 state

        visitor: {

            ImportDeclaration(path, state) {

                const { node } = path;

                // source的值為antd

                if(node.source.value === 'antd'){

                    const specifiers = node.specifiers

                    // 遍歷 specifiers 數組

                    const result = specifiers.map((specifier) => {

                        const local = specifier.local

                        // 構造 source

                        const source = t.stringLiteral(`${node.source.value}/lib/${local.name}`)

                        // 構造 import 語句

                        return t.importDeclaration([t.importDefaultSpecifier(local)], source)

                    })

                    console.log(result)

                    path.replaceWithMultiple(result)

                }

            }

        }

    }

}

驗證方法也很簡單,把這段代碼複製到AST Explorer中,查看輸出結果即可;到這裏,這個“簡易”插件實現完成;

再來回顧一下實現思路:

  1. 對比源碼在語法樹中的差異,明確要做哪些轉換和修改
  2. 分析類型,可以在babel官方,找到類型説明
  3. 在插件模板中,通過visitor訪問對應的類型節點,進行增刪改查
Codemod

上面講解了ast在babel中的基本操作方法,再來看看codemod。

使用antd3的同學,都接觸過antd3到antd4的codemod,這是一個幫助我們自動化的,把antd3的代碼轉換到antd4的一個工具庫;因為它的本質是進行代碼轉換,所以基於babel實現codemod,是完全ok的。但除了代碼轉換,還需要有命令行操作,源代碼讀取,批量執行轉換,日誌輸出等功能,他更是一個功能集合,代碼轉換是其中很重要的一部分;所以,推薦另外一個工具 jscodeshift。他的定位是一個transform runner,所以,我們的核心工作是,定義一系列的transform,也就是轉換規則,剩下的命令行、源碼讀取、批量執行、日誌輸出都可以交給jscodeshift。

準備工作

先定義一個transform,和babel插件很像

import { Transform } from "jscodeshift";



const transform: Transform = (file, api, options) => {

  return null;

};



export default transform;
動手實踐

我們嘗試把Button組件的"type"屬性替換為"status",並把width屬性,添加到style中:

// 輸入

const Component = () => {

  return (

      <Button 

          type="dange"

          width="20"

      />

  )

}



// 輸出

const Component = () => {

  return (

      <Button 

          statue="dange"

          style={{

              width: 20

          }}

      />

  )

}
差異對比
  1. react組件的屬性類型為:JSXIdentifier,屬性"type"修改為"status"
  2. 如果組件有"width"屬性,把該屬性移動到"style"屬性中

查找Button組件的代碼如下:

import { Transform } from "jscodeshift";



const transform = (file, api, options) => {

    const j = api.jscodeshift;

    // 查找jsx節點,通過find方法的第二個參數進行過濾

    return j(file.source).find(j.JSXOpeningElement, {

        name: {

            type: 'JSXIdentifier',

            name: 'Button'

        }

    })

};



export default transform;
屬性替換

接下來,添加屬性替換邏輯,把type替換為status

export default function transformer(file, api) {

  const j = api.jscodeshift;

  return j(file.source)

    .find(j.JSXOpeningElement, {

      name: {

        type: 'JSXIdentifier',

        name: 'Button'

      }

    }).forEach(function(path){

        var attributes = path.value.attributes;

        attributes.forEach(function(node, index){

          const attr = node.name.name;

          if(attr === 'type'){

            // attr為type時,把屬性名替換為 status

            node.name.name = 'status'

          }

        })

     })

    .toSource();

}

在查找JSX元素時,jscodeshift可以直接獲取:j(file.source).findJSXElements() ,這裏使用find代替,find的第二個參數,可以描述過濾條件;

jscodeshift支持鏈式調用,查找到節點後,使用forEach遍歷,當組件的屬性名為type時,把屬性名替換為"status",這裏只考慮了一種情況,還存在 JSXNamespaceName 的場景,比如: <Button n:a />;

處理width

存在width時,獲取width的值,然後刪除該節點;

接下來是創建style節點,類型是 jsxAttribute,把width的值設置回style

...

attributes.forEach(function(node, index){

    const attr = node.name.name;

    if(attr === 'width'){

        // 獲取width的值

        width = node.value.value;

        // 刪除 width 屬性

        attributes.splice(index, 1)

     }

     let width;

     if(width){

        // 構造 style 節點

        var node = j.jsxAttribute(

          // 設置attr的名稱為: style

          j.jsxIdentifier('style'), 

          // 構造 jsxExpressionContainer { }

          // 構造 objectExpression 

          j.jsxExpressionContainer(j.objectExpression([

            j.objectProperty(

              j.identifier('width'),

              j.stringLiteral(width),

            ),

          ])),

        )

        // 插入style節點

        attributes.splice(index, 0, node)

    }

}



...

總結

上面分別介紹了基於babel的實現和jscodeshift的實現,思路一樣,比較簡單,但是要花費額外的時間和精力才能達到比較完美的狀態,尤其是面對大規模的代碼處理時,邊界條件較多,需要考慮的非常全面;但這個投入是值得的,可以把大部分工作自動化的處理;

另外,babel的特長是在ast的處理,jscodeshift更像是功能完備的工具集合,可以把精力聚焦在轉換器的實現,請根據實際場景選擇合適的工具。

The End

Add a new 評論

Some HTML is okay.