Babel是如何讀懂JS代碼的
編者按:本文轉載自安秦的知乎文章,快來一起學習吧!
概述
本文不再介紹Babel是什么也不講怎么用,這類文章很多,我也不覺得自己能寫得更好。這篇文章的關注點是另一個方面,也是很多人會好奇的事情,Babel的工作原理是什么。
Babel工作的三個階段
首先要說明的是,現(xiàn)在前端流行用的WebPack或其他同類工程化工具會將源文件組合起來,這部分并不是Babel完成的,是這些打包工具自己實現(xiàn)的,Babel的功能非常純粹,以字符串的形式將源代碼傳給它,它就會返回一段新的代碼字符串(以及sourcemap)。他既不會運行你的代碼,也不會將多個代碼打包到一起,它就是個編譯器,輸入語言是ES6+,編譯目標語言是ES5。
在Babel官網(wǎng),plugins菜單下藏著一個鏈接:thejameskyle/the-super-tiny-compiler。它已經(jīng)解釋了整個工作過程,有耐心者可以自己研究,當然也可以繼續(xù)看我的文章。
Babel的編譯過程跟絕大多數(shù)其他語言的編譯器大致同理,分為三個階段:
解析:將代碼字符串解析成抽象語法樹
變換:對抽象語法樹進行變換操作
再建:根據(jù)變換后的抽象語法樹再生成代碼字符串
像我們在.babelrc里配置的presets和plugins都是在第2步工作的。
舉個例子,首先你輸入的代碼如下:
if (1 > 0) {
alert('hi');
}
經(jīng)過第1步得到一個如下的對象:
{
"type": "Program", // 程序根節(jié)點
"body": [ // 一個數(shù)組包含所有程序的頂層語句
{
"type": "IfStatement", // 一個if語句節(jié)點
"test": { // if語句的判斷條件
"type": "BinaryExpression", // 一個雙元運算表達式節(jié)點
"operator": ">", // 運算表達式的運算符
"left": { // 運算符左側值
"type": "Literal", // 一個常量表達式
"value": 1 // 常量表達式的常量值
},
"right": { // 運算符右側值
"type": "Literal",
"value": 0
}
},
"consequent": { // if語句條件滿足時的執(zhí)行內(nèi)容
"type": "BlockStatement", // 用{}包圍的代碼塊
"body": [ // 代碼塊內(nèi)的語句數(shù)組
{
"type": "ExpressionStatement", // 一個表達式語句節(jié)點
"expression": {
"type": "CallExpression", // 一個函數(shù)調(diào)用表達式節(jié)點
"callee": { // 被調(diào)用者
"type": "Identifier", // 一個標識符表達式節(jié)點
"name": "alert"
},
"arguments": [ // 調(diào)用參數(shù)
{
"type": "Literal",
"value": "hi"
}
]
}
}
]
},
"alternative": null // if語句條件未滿足時的執(zhí)行內(nèi)容
}
]
}
Babel實際生成的語法樹還會包含更多復雜信息,這里只展示比較關鍵的部分,欲了解更多關于ES語言抽象語法樹規(guī)范可閱讀:The ESTree Spec。
用圖像更簡單地表達上面的結構:

第1步轉換的過程中可以驗證語法的正確性,同時由字符串變?yōu)閷ο蠼Y構后更有利于精準地分析以及進行代碼結構調(diào)整。
第2步原理就很簡單了,就是遍歷這個對象所描述的抽象語法樹,遇到哪里需要做一下改變,就直接在對象上進行操作,比如我把IfStatement給改成WhileStatement就達到了把條件判斷改成循環(huán)的效果。
第3步也簡單,遞歸遍歷這顆語法樹,然后生成相應的代碼,大概的實現(xiàn)邏輯如下:
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); // 將語法樹重新組合成代碼
抽象語法樹是如何產(chǎn)生的
第2、3步相信不用花多少篇幅大家自己都能理解,重點介紹的第一步來了。
解析這一步又分成兩個步驟:
分詞:將整個代碼字符串分割成?語法單元?數(shù)組
語義分析:在分詞結果的基礎之上分析?語法單元之間的關系
我們一步步講。
分詞
首先解釋一下什么是語法單元:語法單元是被解析語法當中具備實際意義的最小單元,通俗點說就是類似于自然語言中的詞語。
看這句話“2020年奧運會將在東京舉行”,不論詞性及主謂關系等,人第一步會把這句話拆分成:2020年、奧運會、將、在、東京、舉行。這就是分詞:把整句話拆分成有意義的最小顆粒,這些小塊不能再被拆分,否則就失去它所能表達的意義了。
那么回到代碼的解析當中,JS代碼有哪些語法單元呢?大致有以下這些(其他語言也許類似但通常都有區(qū)別):
空白:JS中連續(xù)的空格、換行、縮進等這些如果不在字符串里,就沒有任何實際邏輯意義,所以把連續(xù)的空白符直接組合在一起作為一個語法單元。
注釋:行注釋或塊注釋,雖然對于人類來說有意義,但是對于計算機來說知道這是個“注釋”就行了,并不關心內(nèi)容,所以直接作為一個不可再拆的語法單元
字符串:對于機器而言,字符串的內(nèi)容只是會參與計算或展示,里面再細分的內(nèi)容也是沒必要分析的
數(shù)字:JS語言里就有16、10、8進制以及科學表達法等數(shù)字表達語法,數(shù)字也是個具備含義的最小單元
標識符:沒有被引號擴起來的連續(xù)字符,可包含字母、_、$、及數(shù)字(數(shù)字不能作為開頭)。標識符可能代表一個變量,或者true、false這種內(nèi)置常量、也可能是if、return、function這種關鍵字,是哪種語義,分詞階段并不在乎,只要正確切分就好了。
運算符:+、-、*、/、>、<等等
括號:(...)可能表示運算優(yōu)先級、也可能表示函數(shù)調(diào)用,分詞階段并不關注是哪種語義,只把“(”或“)”當做一種基本語法單元
還有其他:如中括號、大括號、分號、冒號、點等等不再一一列舉
分詞的過過程從邏輯來講并不難解釋,但是這是個精細活,要考慮清楚所有的情況。還是以一個代碼為例:
if (1 > 0) {
alert("if \"1 > 0\"");
}我們希望得到的分詞是:
'if' ' ' '(' '1' ' ' '>' ' ' ')' ' ' '{'
'\n ' 'alert' '(' '"if \"1 > 0\""' ')' ';' '\n' '}'
注意其中"if \"1 > 0\""是作為一個語法單元存在,沒有再查分成if、1、>、0這樣,而且其中的轉譯符會阻止字符串早結束。
這拆分過程其實沒啥可取巧的,就是簡單粗暴地一個字符一個字符地遍歷,然后分情況討論,整個實現(xiàn)方法就是順序遍歷和大量的條件判斷。我用一個簡單的實現(xiàn)來解釋,在關鍵的地方注釋,我們只考慮上面那段代碼里存在的語法單元類型。
function tokenizeCode (code) {
const tokens = []; // 結果數(shù)組
for (let i = 0; i < code.length; i++) {
// 從0開始,一個字符一個字符地讀取
let currentChar = code.charAt(i);
if (currentChar === ';') {
// 對于這種只有一個字符的語法單元,直接加到結果當中
tokens.push({
type: 'sep',
value: ';',
});
// 該字符已經(jīng)得到解析,不需要做后續(xù)判斷,直接開始下一個
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, // 記錄這個語法單元目前的內(nèi)容
};
tokens.push(token);
const closer = currentChar;
let escaped = false; // 表示下一個字符是不是被轉譯的
// 進行嵌套循環(huán)遍歷,尋找字符串結尾
for (i++; i < code.length; i++) {
currentChar = code.charAt(i);
// 先將當前遍歷到的字符無條件加到字符串的內(nèi)容當中
token.value += currentChar;
if (escaped) {
// 如果當前轉譯狀態(tài)是true,就將改為false,然后就不特殊處理這個字符
escaped = false;
} else if (currentChar === '\\') {
// 如果當前字符是 \ ,將轉譯狀態(tài)設為true,下一個字符不會被特殊處理
escaped = true;
} else if (currentChar === closer) {
break;
}
}
continue;
}
if (/[0-9]/.test(currentChar)) {
// 數(shù)字是以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)) {
// 如果遍歷到的字符還是數(shù)字的一部分(0到9或小數(shù)點)
// 這里暫不考慮會出現(xiàn)多個小數(shù)點以及其他進制的情況
token.value += currentChar;
} else {
// 遇到不是數(shù)字的字符就退出,需要把 i 往回調(diào),
// 因為當前的字符并不屬于數(shù)字的一部分,需要做后續(xù)解析
i--;
break;
}
}
continue;
}
if (/[a-zA-Z\$\_]/.test(currentChar)) {
// 標識符是以字母、$、_開始的
const token = {
type: 'identifier',
value: currentChar,
};
tokens.push(token);
// 與數(shù)字同理
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)) {
// 連續(xù)的空白字符組合到一起
const token = {
type: 'whitespace',
value: currentChar,
};
tokens.push(token);
// 與數(shù)字同理
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");
}
`);
以上代碼是我個人的實現(xiàn)方式,與babel實際略有不同,但主要思路一樣。
執(zhí)行結果如下:
[
{ 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" },
]
經(jīng)過這一步的分詞,這個數(shù)組就比攤開的字符串更方便進行下一步處理了。
語義分析
語義分析就是把詞匯進行立體的組合,確定有多重意義的詞語最終是什么意思、多個詞語之間有什么關系以及又應該再哪里斷句等。
在編程語言解釋當中,這就是要最終生成語法樹的步驟了。不像自然語言,像“從句”這種結構往往最多只有一層,編程語言的各種從屬關系更加復雜。
在編程語言的解析中有兩個很相似但是又有區(qū)別的重要概念:
語句:語句是一個具備邊界的代碼區(qū)域,相鄰的兩個語句之間從語法上來講互不干擾,調(diào)換順序雖然可能會影響執(zhí)行結果,但不會產(chǎn)生語法錯誤
比如return true、var a = 10、if (...) {...}表達式:最終有個結果的一小段代碼,它的特點是可以原樣嵌入到另一個表達式
比如myVar、1+1、str.replace('a', 'b')、i < 10 && i > 0等
很多情況下一個語句可能只包含一個表達式,比如console.log('hi');。estree標準當中,這種語句節(jié)點稱作ExpressionStatement。
語義分析的過程又是個遍歷語法單元的過程,不過相比較而言更復雜,因為分詞過程中,每個語法單元都是獨立平鋪的,而語法分析中,語句和表達式會以樹狀的結構互相包含。針對這種情況我們可以用棧,也可以用遞歸來實現(xiàn)。
我繼續(xù)上面的例子給出語義分析的代碼,代碼很長,先在最開頭說明幾個函數(shù)是做什么的:
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');
}
// 后續(xù)的一個表達式是 if 的判斷條件
statement.test = nextExpression();
// 判斷條件之后必須是 )
nextToken();
if (curToken.type !== 'parens' || curToken.value !== ')') {
throw new Error('Expected ) after if test expression');
}
// 下一個語句是 if 成立時執(zhí)行的語句
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 === '(') {
// 如果一個標識符后面緊跟著 ( ,說明是個函數(shù)調(diào)用表達式
const expr = {
type: 'CallExpression',
caller: identifier,
arguments: [],
};
stash();
nextToken();
if (curToken.type === 'parens' && curToken.value === ')') {
// 如果下一個符合直接就是 ) ,說明沒有參數(shù)
commit();
} else {
// 讀取函數(shù)調(diào)用參數(shù)
rewind();
while (i < tokens.length) {
// 將下一個表達式加到arguments當中
expr.arguments.push(nextExpression());
nextToken();
// 遇到 ) 結束
if (curToken.type === 'parens' && curToken.value === ')') {
break;
}
// 參數(shù)間必須以 , 相間隔
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') {
// 數(shù)字或字符串,說明此處是個常量表達式
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" },
]);
最終得到結果:
{
"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
}
]
}
以上就是語義解析的部分主要思路。注意現(xiàn)在的nextExpression已經(jīng)頗為復雜,但實際實現(xiàn)要比現(xiàn)在這里展示的要更復雜很多,因為這里根本沒有考慮單元運算符、運算優(yōu)先級等等。
結語
真正看下來,其實沒有哪個地方的原理特別高深莫測,就是精細活,需要考慮到各種各樣的情況??傊鲆粋€完整的語法解釋器需要的是十分的細心與耐心。
在并不是特別遠的過去,做web項目,前端技術都還很簡單,甚至那時候的網(wǎng)頁都盡量不用JavaScript。之后jQuery的誕生真正地讓JS成為了web應用開發(fā)核心,web前端工程師這種職業(yè)也才真正獨立出來。但后來隨著語言預處理和打包等技術的出現(xiàn),前端真的是越來越強大但是技術棧也真的是變得越來越復雜。雖然有種永遠都學不完的感覺,但這更能體現(xiàn)出我們前端工程存在的價值,不是嗎?
分享前端好文,點亮?在看?
