Babel是如何讀懂JS代碼的

概述

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

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


Babel工作的三個階段

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

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

解析:將代碼字符串解析成抽象語法樹

變換:對抽象語法樹進行變換操作

再建:根據變換後的抽象語法樹再生成代碼字符串

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

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

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

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

Babel是如何讀懂JS代碼的

Babel實際生成的語法樹還會包含更多複雜信息,這裡只展示比較關鍵的部分

Babel是如何讀懂JS代碼的

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

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

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

Babel是如何讀懂JS代碼的


抽象語法樹是如何產生的

第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' ' ' '>' ' ' ')' ' ' '{'
'\n ' 'alert' '(' '"if "1 > 0""' ')' ';' '\n' '}'

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

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

Babel是如何讀懂JS代碼的

Babel是如何讀懂JS代碼的

Babel是如何讀懂JS代碼的

const tokens = tokenizeCode(`
if (1 > 0) {
alert("if 1 > 0");
}

執行結果如下:

Babel是如何讀懂JS代碼的

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


語義分析

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

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

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

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

比如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: "\n" },
{ 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: "\n " },
{ type: "identifier", value: "alert" },
{ type: "parens", value: "(" },
{ type: "string", value: ""if 1 > 0"" },
{ type: "parens", value: ")" },
{ type: "sep", value: ";" },
{ type: "whitespace", value: "\n" },
{ type: "brace", value: "}" },
{ type: "whitespace", value: "\n" },
]);

最終得到結果:

Babel是如何讀懂JS代碼的

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


結語

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

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


分享到:


相關文章: