作者:京東科技 周明亮
AST 基礎與功能
在前端裡面有一個很重要的概念,也是最原子化的內容,就是 AST ,幾乎所有的框架,都是基於 AST 進行改造執行,比如:React / Vue /Taro 等等。 多端的執行使用,都離不開 AST 這個概念。
在大家理解相關原理和背景後,我們可以透過手寫簡單的編譯器,簡單實現一個 Javascript 的程式碼編譯器,編譯後在瀏覽器端正常執行。
建立數字小明,等於六加一。
建立數字小亮,等於七減二。
輸出,小明乘小亮。
透過實現一個自定義的編譯器,我們發現我們自己也能寫出很多新的框架。最終目標都是透過編譯轉換,翻譯為瀏覽器識別的 Javascript + CSS + HTML。
沒錯!翻譯翻譯~
當然我們也可以以這個為基礎,去實現跨端的框架,直接翻譯為機器碼,跑到各種硬體上。當然一個人肯定比較困難,你會遇到各種各樣的問題需要解決,不過沒關係,只要你有好的想法,拉上一群人,你就能實現。
大家記得點贊,評論,收藏,一鍵三連啊~
分析器
說到這個程式碼語義化操作前,我們先說說分析器,其實就是編譯原理。當你寫了一段程式碼,要想讓機器知道,你寫了啥。
那機器肯定是要開始掃描,掃描每一個關鍵詞,每一個符號,我們將進行詞法分析的程式或者函式叫作詞法分析器(Lexical analyzer),透過它的掃描可以將字元序列轉換為單詞(Token)序列的過程。
掃描到了關鍵詞,我們怎麼才能把它按照規則,轉換為機器認識的特定規則呢?比如你掃描到:
const a = 1
機器怎麼知道要建立一個 變數a並且等於1呢?
所以,這時候就引入一個概念:語法分析器(Syntactic analysis,Parser)。透過語法分析器,不斷的呼叫詞法分析器,進行語法檢查、並構建由輸入的單片語成的資料結構(一般是語法分析樹、抽象語法樹等層次化的資料結構)。
在JS的世界裡,這個掃描後得到的資料結構抽象語法樹 【AST】。可能很多人聽過這個概念,但是具體沒有深入瞭解。機緣巧合,剛好我需要用到這個玩意,今天就簡單聊聊。
抽象語法樹 AST
AST是Abstract Syntax Tree的縮寫,也就是:抽象語法樹。在程式碼的世界裡,它叫這個。在語言的世界裡面,他叫語法分析樹。
語言世界,舉個例子:
我寫文章。
語法分析樹:
主語:我,人稱代詞。
謂語:寫,動詞。
賓語:文章,名詞。
長一點的可能會有:主謂賓定狀補。是不是發現好熟悉,想當年大家學語文和英語,那是一定要進行語法分析,方便你理解句子要表達的含義。
PS:對我來說,語法老難了!!!哈哈哈,大家是不是找到感覺了~
接下來我們講講程式碼裡面的抽象語法樹。
const me = "我"
function write() {
console.log("文章")
}
那我們用來進行語法分析,能夠得到什麼內容了?這時候我們可以藉助已有的工具,將他們進行分析,進行一個初級入門。
其實我們也可以完全自己進行分析,不過這樣就不容易入門,定義的語法規則很多,如果只是看,很容易就被勸退了。而透過輔助工具,我們可以很快接受相關的概念。
常用的工具有很多,比如:Recast 、Babel、Acorn 等等
也可以使用線上 AST 解析:AST Explorer,左上角選單可以切換到各種解析工具,並且支援各類程式語言的解析,強大好用,可以用來學習,幫助你理解 AST。
為了幫助大家理解,我們一點點的進行解析,並且去掉了部分屬性,留下主幹部分,完整的可以透過線上工具檢視。【不同解析器,對於根節點或者部分屬性稍有區別,但是本質是一樣的。】
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "me"
},
"init": {
"type": "Literal",
"value": "我",
"raw": "\"我\""
}
}
],
"kind": "const"
},
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "write"
},
"params": [],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
}
},
"arguments": [
{
"type": "Literal",
"value": "文章",
"raw": "\"文章\""
}
]
}
}
]
}
}
],
"sourceType": "module"
}
接下來,我們一個一個節點看,首先是第一個節點Program
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"kind": "const"
...
},
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "write"
},
....
}
],
"sourceType": "module"
}
Program是程式碼程式的根節點,透過它進行節點一層一層的遍歷操作。 上面我們看出它有兩個節點,一個是變數宣告節點,另外一個是函式宣告節點。
如果我們再定義一個變數或者函式,這時候 body 就又會產生一個節點。我們要掃描程式碼檔案時,我們就是基於 body 進行層層的節點掃描,直到把所有的節點掃描完成。
{
"type": "VariableDeclaration",
"declarations": [
{
"type": "VariableDeclarator",
"id": {
"type": "Identifier",
"name": "me"
},
"init": {
"type": "Literal",
"value": "我",
"raw": "\"我\""
}
}
],
"kind": "const"
},
上面對應的程式碼,就是const me = "我",這個節點告訴我們。 宣告一個變數,使用型別是:VariableDeclaration, 他的唯一標識名是:me,初始化值:"我"。
後續的函式分析,也是一樣的。
{
"type": "FunctionDeclaration",
"id": {
"type": "Identifier",
"name": "write"
},
"params": [],
"body": {
"type": "BlockStatement",
"body": [
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "console"
},
"property": {
"type": "Identifier",
"name": "log"
},
},
"arguments": [
{
"type": "Literal",
"value": "文章",
"raw": "\"文章\""
}
],
}
}
]
}
}
這個節點,清楚的告訴我們,這個函式名是什麼,他裡面有哪些內容,入參是什麼,呼叫了什麼函式物件。
我們發現,透過語法分析器的解析,我們可以把程式碼,變成一個物件。這個物件將程式碼分割為原子化的內容,很容易能夠幫助機器或者我們去理解它的組成。
這個就是分析器的作用,我們不再是一大段一大段的看程式碼邏輯,而是一小段一小段的看節點。
有了這個我們可以幹什麼呢?
AST 在 JS 中的用途
1. 自定義語法分析器,寫一個新的框架。
透過對現有的 AST 理解,我們可以依葫蘆畫瓢,寫出自定義的語法分析器,轉成自定義的抽象語法樹,再進行解析轉為瀏覽器可識別的 Javascript 語言,或者其他硬體上能識別的語言。
比如:React / Vue 等等框架。其實這些框架,就是自定義了一套語法分析器,用他們特定的語言,進行轉換,翻譯翻譯,生成相關的DOM節點,操作函式等等 JS 函式。
2. 利用已有語法分析器,實現多端執行。
透過已有的 AST,我們將程式碼進行翻譯翻譯,實現跨平臺多端執行。我們將得到程式碼進行語法解析,透過遍歷所有的節點,我們將他們進行改造,使得它能夠執行在其他的平臺上。
比如:Taro / uni-app 等等框架。我們只要寫一次程式碼,框架透過分析轉換,就可以執行到 H5 / 小程式等等相關的客戶端。
3. 進行程式碼改造,預編譯增強處理。
依舊是透過已有的 AST,我們將程式碼進行分析。再進行程式碼混淆,程式碼模組化處理,自動進行模組引入,低版本相容處理。
比如:Webpack / Vite 等等打包工具。我們寫完程式碼,透過他們的處理,進行增強編譯,增強程式碼的健壯性。
AST 的應用實踐
我們在進行框架的改造或者適配時,我們可能才會用到這個。常規的方法,可能有兩種:
- 按照特定的寫法,透過正規表示式,直接進行大段程式碼替換。
- /** mingliang start /consta=1/ mingliang end /
如,我們找到這段程式碼註釋,直接透過code.replace(/mingliang/g, 'xxxx')類似這種方式替換。
- 透過引入執行,改造相關的變數,再重新寫入。
// a.js
cost config = { a: 1 }
return config
我們可能先let config = require(a.js)執行這個檔案,我們就得到了這個config這個變數值。
之後我們改寫變數config.a = 2,
最後,重新透過fs.writeSync('a.js', 'return ' + JSON.stringify(config, null, 2))寫入。
現在,我們就可以掌握新的方法,進行程式碼改造。