豆皮粉兒們,又見面了,今天這一期,由字節跳動數據平台的太郎醬,帶大家走進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之前,先了解下解析流程,分為三步:
- source code --> ast (源代碼解析為ast)
- traverse ast (遍歷ast,訪問樹中的各個節點,對節點做各種操作)
- 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的思維來思考,實現步驟如下:
- 查找代碼中的 import 語句,且必須是 import { xxx } from 'antd'
- 把步驟一找到的節點,轉換為 import Button from 'antd/lib/button'
實現步驟
- 打開神器: AST Explorer,把第一行代碼複製到神器中
- 點擊代碼中的 import 關鍵字,會自動定位到對應的節點,結構如下:
ImportDeclaration {
type: "ImportDeclaration",
specifiers: [{ // 對應 {} 括號中的組件
ImportSpecifier: {
type: "ImportSpecifier",
imported: {
type: "Identifier",
name: "Button"
}
}
}]
source: {
type: "StringLiteral",
value: "antd"
},
...
}
源碼被轉換成帶有類型和屬性的對象,不管是關鍵字、變量聲明,還是字面量值,都有對應類型;
- import 語句對應的類型是: ImportDeclaration
- { Button }對應的是 specifiers 數組,示例中只引入了 "Button",所以specifiers數組中的元素只有一個
- specifiers中的元素,也就是 Button,類型是 ImportSpecifier;
- '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中,查看輸出結果即可;到這裏,這個“簡易”插件實現完成;
再來回顧一下實現思路:
- 對比源碼在語法樹中的差異,明確要做哪些轉換和修改
- 分析類型,可以在babel官方,找到類型説明
- 在插件模板中,通過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
}}
/>
)
}
差異對比
- react組件的屬性類型為:JSXIdentifier,屬性"type"修改為"status"
- 如果組件有"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更像是功能完備的工具集合,可以把精力聚焦在轉換器的實現,請根據實際場景選擇合適的工具。