Babel是如何讀懂JS代碼的

概述

稍微了解行業現狀的開發者都知道,現在前端「ES6即正義」,然而瀏覽器的支持還是進行時。所以我們會用一個神奇的工具將ES6都給轉換成目前支持比較廣泛的ES5語法。對,說的就是Babel。

本文不再介紹Babel是什麼也不講怎麼用,這類文章很多,我也不覺得自己能寫得更好。這篇文章的關注點是另一個方面,也是很多人會好奇的事情,Babel的工作原理是什麼。

Babel工作的三個階段

首先要說明的是,現在前端流行用的WebPack或其他同類工程化工具會將源文件組合起來,這部分並不是Babel完成的,是這些打包工具自己實現的,Babel的功能非常純粹,以字元串的形式將源代碼傳給它,它就會返回一段新的代碼字元串(以及sourcemap)。他既不會運行你的代碼,也不會將多個代碼打包到一起,它就是個編譯器,輸入語言是ES6+,編譯目標語言是ES5。

在Babel官網,plugins菜單下藏著一個鏈接:thejameskyle/the-super-tiny-compiler。它已經解釋了整個工作過程,有耐心者可以自己研究,當然也可以繼續看我的文章。

Babel的編譯過程跟絕大多數其他語言的編譯器大致同理,分為三個階段:

  1. 解析:將代碼字元串解析成抽象語法樹

  2. 變換:對抽象語法樹進行變換操作
  3. 再建:根據變換後的抽象語法樹再生成代碼字元串

像我們在.babelrc里配置的presets和plugins都是在第2步工作的。

舉個例子,首先你輸入的代碼如下:

if (1 > 0) { alert("hi");}

經過第1步得到一個如下的對象:

{ "type": "Program", // 程序根節點 "body": [ // 一個數組包含所有程序的頂層語句 { "type": "IfStatement", // 一個if語句節點 "test": { // if語句的判斷條件 "type": "BinaryExpression", // 一個雙元運算表達式節點 "operator": ">", // 運算表達式的運算符 "left": { // 運算符左側值 "type": "Literal", // 一個常量表達式 "value": 1 // 常量表達式的常量值 }, "right": { // 運算符右側值 "type": "Literal", "value": 0 } }, "consequent": { // if語句條件滿足時的執行內容 "type": "BlockStatement", // 用{}包圍的代碼塊 "body": [ // 代碼塊內的語句數組 { "type": "ExpressionStatement", // 一個表達式語句節點 "expression": { "type": "CallExpression", // 一個函數調用表達式節點 "callee": { // 被調用者 "type": "Identifier", // 一個標識符表達式節點 "name": "alert" }, "arguments": [ // 調用參數 { "type": "Literal", "value": "hi" } ] } } ] }, "alternative": null // if語句條件未滿足時的執行內容 } ]}

Babel實際生成的語法樹還會包含更多複雜信息,這裡只展示比較關鍵的部分,欲了解更多關於ES語言抽象語法樹規範可閱讀:The ESTree Spec。

用圖像更簡單地表達上面的結構:

第1步轉換的過程中可以驗證語法的正確性,同時由字元串變為對象結構後更有利於精準地分析以及進行代碼結構調整。

第2步原理就很簡單了,就是遍歷這個對象所描述的抽象語法樹,遇到哪裡需要做一下改變,就直接在對象上進行操作,比如我把IfStatement給改成WhileStatement就達到了把條件判斷改成循環的效果。

第3步也簡單,遞歸遍歷這顆語法樹,然後生成相應的代碼,大概的實現邏輯如下:

const types = { Program (node) { return node.body.map(child => generate(child)); }, IfStatement (node) { let code = `if (${generate(node.test)}) ${generate(node.consequent)}`; if (node.alternative) { code += `else ${generate(node.alternative)}`; } return code; }, BlockStatement (node) { let code = node.body.map(child => generate(child)); code = `{ ${code} }`; return code; }, ......};function generate(node) { return types[node.type](node);}const ast = Babel.parse(...); // 將代碼解析成語法樹const generatedCode = generate(ast); // 將語法樹重新組合成代碼

抽象語法樹是如何產生的

第2、3步相信不用花多少篇幅大家自己都能理解,重點介紹的第一步來了。

解析這一步又分成兩個步驟:

  1. 分詞:將整個代碼字元串分割成 語法單元 數組
  2. 語義分析:在分詞結果的基礎之上分析 語法單元之間的關係

我們一步步講。

分詞

首先解釋一下什麼是語法單元:語法單元是被解析語法當中具備實際意義的最小單元,通俗點說就是類似於自然語言中的詞語。

看這句話「2020年奧運會將在東京舉行」,不論詞性及主謂關係等,人第一步會把這句話拆分成:2020年、奧運會、將、在、東京、舉行。這就是分詞:把整句話拆分成有意義的最小顆粒,這些小塊不能再被拆分,否則就失去它所能表達的意義了。

那麼回到代碼的解析當中,JS代碼有哪些語法單元呢?大致有以下這些(其他語言也許類似但通常都有區別):

  • 空白:JS中連續的空格、換行、縮進等這些如果不在字元串里,就沒有任何實際邏輯意義,所以把連續的空白符直接組合在一起作為一個語法單元。
  • 注釋:行注釋或塊注釋,雖然對於人類來說有意義,但是對於計算機來說知道這是個「注釋」就行了,並不關心內容,所以直接作為一個不可再拆的語法單元
  • 字元串:對於機器而言,字元串的內容只是會參與計算或展示,裡面再細分的內容也是沒必要分析的
  • 數字:JS語言里就有16、10、8進位以及科學表達法等數字表達語法,數字也是個具備含義的最小單元
  • 標識符:沒有被引號擴起來的連續字元,可包含字母、_、$、及數字(數字不能作為開頭)。標識符可能代表一個變數,或者true、false這種內置常量、也可能是if、return、function這種關鍵字,是哪種語義,分詞階段並不在乎,只要正確切分就好了。
  • 運算符:+、-、*、/、>、<等等
  • 括弧:(...)可能表示運算優先順序、也可能表示函數調用,分詞階段並不關注是哪種語義,只把「(」或「)」當做一種基本語法單元
  • 還有其他:如中括弧、大括弧、分號、冒號、點等等不再一一列舉

分詞的過過程從邏輯來講並不難解釋,但是這是個精細活,要考慮清楚所有的情況。還是以一個代碼為例:

if (1 > 0) { alert("if "1 > 0"");}

我們希望得到的分詞是:

"if" " " "(" "1" " " ">" " " ")" " " "{""
" "alert" "(" ""if "1 > 0""" ")" ";" "
" "}"

注意其中"if "1 > 0""是作為一個語法單元存在,沒有再查分成if、1、>、0這樣,而且其中的轉譯符會阻止字元串早結束。

這拆分過程其實沒啥可取巧的,就是簡單粗暴地一個字元一個字元地遍歷,然後分情況討論,整個實現方法就是順序遍歷和大量的條件判斷。我用一個簡單的實現來解釋,在關鍵的地方注釋,我們只考慮上面那段代碼里存在的語法單元類型。

function tokenizeCode (code) { const tokens = []; // 結果數組 for (let i = 0; i < code.length; i++) { // 從0開始,一個字元一個字元地讀取 let currentChar = code.charAt(i); if (currentChar === ";") { // 對於這種只有一個字元的語法單元,直接加到結果當中 tokens.push({ type: "sep", value: ";", }); // 該字元已經得到解析,不需要做後續判斷,直接開始下一個 continue; } if (currentChar === "(" || currentChar === ")") { // 與 ; 類似只是語法單元類型不同 tokens.push({ type: "parens", value: currentChar, }); continue; } if (currentChar === "}" || currentChar === "{") { // 與 ; 類似只是語法單元類型不同 tokens.push({ type: "brace", value: currentChar, }); continue; } if (currentChar === ">" || currentChar === "<") { // 與 ; 類似只是語法單元類型不同 tokens.push({ type: "operator", value: currentChar, }); continue; } if (currentChar === """ || currentChar === """) { // 引號表示一個字元傳的開始 const token = { type: "string", value: currentChar, // 記錄這個語法單元目前的內容 }; tokens.push(token); const closer = currentChar; let escaped = false; // 表示下一個字元是不是被轉譯的 // 進行嵌套循環遍歷,尋找字元串結尾 for (i++; i < code.length; i++) { currentChar = code.charAt(i); // 先將當前遍歷到的字元無條件加到字元串的內容當中 token.value += currentChar; if (escaped) { // 如果當前轉譯狀態是true,就將改為false,然後就不特殊處理這個字元 escaped = false; } else if (currentChar === "\") { // 如果當前字元是 ,將轉譯狀態設為true,下一個字元不會被特殊處理 escaped = true; } else if (currentChar === closer) { break; } } continue; } if (/[0-9]/.test(currentChar)) { // 數字是以0到9的字元開始的 const token = { type: "number", value: currentChar, }; tokens.push(token); for (i++; i < code.length; i++) { currentChar = code.charAt(i); if (/[0-9.]/.test(currentChar)) { // 如果遍歷到的字元還是數字的一部分(0到9或小數點) // 這裡暫不考慮會出現多個小數點以及其他進位的情況 token.value += currentChar; } else { // 遇到不是數字的字元就退出,需要把 i 往回調, // 因為當前的字元並不屬於數字的一部分,需要做後續解析 i--; break; } } continue; } if (/[a-zA-Z$\_]/.test(currentChar)) { // 標識符是以字母、$、_開始的 const token = { type: "identifier", value: currentChar, }; tokens.push(token); // 與數字同理 for (i++; i < code.length; i++) { currentChar = code.charAt(i); if (/[a-zA-Z0-9$\_]/.test(currentChar)) { token.value += currentChar; } else { i--; break; } } continue; } if (/s/.test(currentChar)) { // 連續的空白字元組合到一起 const token = { type: "whitespace", value: currentChar, }; tokens.push(token); // 與數字同理 for (i++; i < code.length; i++) { currentChar = code.charAt(i); if (/s]/.test(currentChar)) { token.value += currentChar; } else { i--; break; } } continue; } // 還可以有更多的判斷來解析其他類型的語法單元 // 遇到其他情況就拋出異常表示無法理解遇到的字元 throw new Error("Unexpected " + currentChar); } return tokens;}const tokens = tokenizeCode(`if (1 > 0) { alert("if 1 > 0");}`);

以上代碼是我個人的實現方式,與babel實際略有不同,但主要思路一樣。

執行結果如下:

[ { type: "whitespace", value: "
"
}, { type: "identifier", value: "if" }, { type: "whitespace", value: " " }, { type: "parens", value: "(" }, { type: "number", value: "1" }, { type: "whitespace", value: " " }, { type: "operator", value: ">" }, { type: "whitespace", value: " " }, { type: "number", value: "0" }, { type: "parens", value: ")" }, { type: "whitespace", value: " " }, { type: "brace", value: "{" }, { type: "whitespace", value: "
"
}, { type: "identifier", value: "alert" }, { type: "parens", value: "(" }, { type: "string", value: ""if 1 > 0"" }, { type: "parens", value: ")" }, { type: "sep", value: ";" }, { type: "whitespace", value: "
"
}, { type: "brace", value: "}" }, { type: "whitespace", value: "
"
},]

經過這一步的分詞,這個數組就比攤開的字元串更方便進行下一步處理了。

語義分析

語義分析就是把辭彙進行立體的組合,確定有多重意義的詞語最終是什麼意思、多個詞語之間有什麼關係以及又應該再哪裡斷句等。

在編程語言解釋當中,這就是要最終生成語法樹的步驟了。不像自然語言,像「從句」這種結構往往最多只有一層,編程語言的各種從屬關係更加複雜。

在編程語言的解析中有兩個很相似但是又有區別的重要概念:

  • 語句:語句是一個具備邊界的代碼區域,相鄰的兩個語句之間從語法上來講互不干擾,調換順序雖然可能會影響執行結果,但不會產生語法錯誤

    比如return true、var a = 10、if (...) {...}
  • 表達式:最終有個結果的一小段代碼,它的特點是可以原樣嵌入到另一個表達式

    比如myVar、1+1、str.replace("a", "b")、i < 10 && i > 0等

很多情況下一個語句可能只包含一個表達式,比如console.log("hi");。estree標準當中,這種語句節點稱作ExpressionStatement。

語義分析的過程又是個遍歷語法單元的過程,不過相比較而言更複雜,因為分詞過程中,每個語法單元都是獨立平鋪的,而語法分析中,語句和表達式會以樹狀的結構互相包含。針對這種情況我們可以用棧,也可以用遞歸來實現。

我繼續上面的例子給出語義分析的代碼,代碼很長,先在最開頭說明幾個函數是做什麼的:

  • nextStatement:讀取並返回下一個語句
  • nextExpression:讀取並返回下一個表達式
  • nextToken:讀取下一個語法單元(或稱符號),賦值給curToken
  • stash:暫存當前讀取符號的位置,方便在需要的時候返回
  • rewind:返回到上一個暫存點
  • commit:上一個暫存點不再被需要,將其銷毀

這裡stash、rewind、commit都跟讀取位置暫存相關,什麼樣的情況會需要返回到暫存點呢?有時同一種語法單元有可能代表不同類型的表達式的開始。先stash,然後按照其中一種嘗試解析,如果解析成功了,那麼暫存點就沒用了,commit將其銷毀。如果解析失敗了,就用rewind回到原來的位置再按照另一種方式嘗試去解析。

以下是代碼:

function parse (tokens) { let i = -1; // 用於標識當前遍歷位置 let curToken; // 用於記錄當前符號 // 讀取下一個語句 function nextStatement () { // 暫存當前的i,如果無法找到符合條件的情況會需要回到這裡 stash(); // 讀取下一個符號 nextToken(); if (curToken.type === "identifier" && curToken.value === "if") { // 解析 if 語句 const statement = { type: "IfStatement", }; // if 後面必須緊跟著 ( nextToken(); if (curToken.type !== "parens" || curToken.value !== "(") { throw new Error("Expected ( after if"); } // 後續的一個表達式是 if 的判斷條件 statement.test = nextExpression(); // 判斷條件之後必須是 ) nextToken(); if (curToken.type !== "parens" || curToken.value !== ")") { throw new Error("Expected ) after if test expression"); } // 下一個語句是 if 成立時執行的語句 statement.consequent = nextStatement(); // 如果下一個符號是 else 就說明還存在 if 不成立時的邏輯 if (curToken === "identifier" && curToken.value === "else") { statement.alternative = nextStatement(); } else { statement.alternative = null; } commit(); return statement; } if (curToken.type === "brace" && curToken.value === "{") { // 以 { 開頭表示是個代碼塊,我們暫不考慮JSON語法的存在 const statement = { type: "BlockStatement", body: [], }; while (i < tokens.length) { // 檢查下一個符號是不是 } stash(); nextToken(); if (curToken.type === "brace" && curToken.value === "}") { // } 表示代碼塊的結尾 commit(); break; } // 還原到原來的位置,並將解析的下一個語句加到body rewind(); statement.body.push(nextStatement()); } // 代碼塊語句解析完畢,返回結果 commit(); return statement; } // 沒有找到特別的語句標誌,回到語句開頭 rewind(); // 嘗試解析單表達式語句 const statement = { type: "ExpressionStatement", expression: nextExpression(), }; if (statement.expression) { nextToken(); if (curToken.type !== "EOF" && curToken.type !== "sep") { throw new Error("Missing ; at end of expression"); } return statement; } } // 讀取下一個表達式 function nextExpression () { nextToken(); if (curToken.type === "identifier") { const identifier = { type: "Identifier", name: curToken.value, }; stash(); nextToken(); if (curToken.type === "parens" && curToken.value === "(") { // 如果一個標識符後面緊跟著 ( ,說明是個函數調用表達式 const expr = { type: "CallExpression", caller: identifier, arguments: [], }; stash(); nextToken(); if (curToken.type === "parens" && curToken.value === ")") { // 如果下一個符合直接就是 ) ,說明沒有參數 commit(); } else { // 讀取函數調用參數 rewind(); while (i < tokens.length) { // 將下一個表達式加到arguments當中 expr.arguments.push(nextExpression()); nextToken(); // 遇到 ) 結束 if (curToken.type === "parens" && curToken.value === ")") { break; } // 參數間必須以 , 相間隔 if (curToken.type !== "comma" && curToken.value !== ",") { throw new Error("Expected , between arguments"); } } } commit(); return expr; } rewind(); return identifier; } if (curToken.type === "number" || curToken.type === "string") { // 數字或字元串,說明此處是個常量表達式 const literal = { type: "Literal", value: eval(curToken.value), }; // 但如果下一個符號是運算符,那麼這就是個雙元運算表達式 // 此處暫不考慮多個運算銜接,或者有變數存在 stash(); nextToken(); if (curToken.type === "operator") { commit(); return { type: "BinaryExpression", left: literal, right: nextExpression(), }; } rewind(); return literal; } if (curToken.type !== "EOF") { throw new Error("Unexpected token " + curToken.value); } } // 往後移動讀取指針,自動跳過空白 function nextToken () { do { i++; curToken = tokens[i] || { type: "EOF" }; } while (curToken.type === "whitespace"); } // 位置暫存棧,用於支持很多時候需要返回到某個之前的位置 const stashStack = []; function stash (cb) { // 暫存當前位置 stashStack.push(i); } function rewind () { // 解析失敗,回到上一個暫存的位置 i = stashStack.pop(); curToken = tokens[i]; } function commit () { // 解析成功,不需要再返回 stashStack.pop(); } const ast = { type: "Program", body: [], }; // 逐條解析頂層語句 while (i < tokens.length) { const statement = nextStatement(); if (!statement) { break; } ast.body.push(statement); } return ast;}const ast = parse([ { type: "whitespace", value: "
"
}, { type: "identifier", value: "if" }, { type: "whitespace", value: " " }, { type: "parens", value: "(" }, { type: "number", value: "1" }, { type: "whitespace", value: " " }, { type: "operator", value: ">" }, { type: "whitespace", value: " " }, { type: "number", value: "0" }, { type: "parens", value: ")" }, { type: "whitespace", value: " " }, { type: "brace", value: "{" }, { type: "whitespace", value: "
"
}, { type: "identifier", value: "alert" }, { type: "parens", value: "(" }, { type: "string", value: ""if 1 > 0"" }, { type: "parens", value: ")" }, { type: "sep", value: ";" }, { type: "whitespace", value: "
"
}, { type: "brace", value: "}" }, { type: "whitespace", value: "
"
},]);

最終得到結果:

{ "type": "Program", "body": [ { "type": "IfStatement", "test": { "type": "BinaryExpression", "left": { "type": "Literal", "value": 1 }, "right": { "type": "Literal", "value": 0 } }, "consequent": { "type": "BlockStatement", "body": [ { "type": "ExpressionStatement", "expression": { "type": "CallExpression", "caller": { "type": "Identifier", "value": "alert" }, "arguments": [ { "type": "Literal", "value": "if 1 > 0" } ] } } ] }, "alternative": null } ]}

以上就是語義解析的部分主要思路。注意現在的nextExpression已經頗為複雜,但實際實現要比現在這裡展示的要更複雜很多,因為這裡根本沒有考慮單元運算符、運算優先順序等等。

結語

真正看下來,其實沒有哪個地方的原理特別高深莫測,就是精細活,需要考慮到各種各樣的情況。總之要做一個完整的語法解釋器需要的是十分的細心與耐心。

在並不是特別遠的過去,做web項目,前端技術都還很簡單,甚至那時候的網頁都盡量不用JavaScript。之後jQuery的誕生真正地讓JS成為了web應用開發核心,web前端工程師這種職業也才真正獨立出來。但後來隨著語言預處理和打包等技術的出現,前端真的是越來越強大但是技術棧也真的是變得越來越複雜。雖然有種永遠都學不完的感覺,但這更能體現出我們前端工程存在的價值,不是嗎?

推薦閱讀:

Babel 編譯出來還是 ES 6?難道只能上 polyfill?
Babel的ES6翻譯存在明顯的缺陷,為什麼沒怎麼見人提過這種大坑?
有了Babel的話還在使用TypeScript的優勢在哪?
如何評價 Webpack 2 新引入的 Tree-shaking 代碼優化技術?

TAG:JavaScript | Babel | 编译原理 |