往期
- 面試官系列(1): 如何實現深克隆
- 面試官系列(2): Event Bus的實現
- 面試官系列(3): 前端路由的實現
- 面試官系列(4): 基於Proxy 資料劫持的雙向繫結優勢所在
- 面試官系列(5): 你為什麼使用前端框架
- 面試官系列(6): 寫過『通用前端元件』嗎?
前言
Babel 是現代 JavaScript 語法轉換器,幾乎在任何現代前端專案中都能看到他的身影,其背後的原理對於大部分開發者還屬於黑盒,不過 Babel 作為一個工具真的有了解背後原理的必要嗎?
如果只是 Babel 可能真沒有必要,問題是其背後的原理在我們開發中應用過於廣泛了,包括不限於: eslint jshint stylelint css-in-js prettier jsx vue-template uglify-js postcss less 等等等等,從模板到程式碼檢測,從混淆壓縮到程式碼轉換,甚至編輯器的程式碼高亮都與之息息相關.
如果有興趣就可以搞一些黑魔法: 前端工程師可以用編譯原理做什麼?
文章目錄
- 程式碼解析(實現 Parser)
- 程式碼轉換(實現 transformer)
- 程式碼生成
前置
Babel 大概分為三大部分:
- 解析: 將程式碼(其實就是字串)轉換成 AST( 抽象語法樹)
- 轉換: 訪問 AST 的節點進行變換操作生成新的 AST
- 生成: 以新的 AST 為基礎生成程式碼
我們主要通過打造一個微型 babel 來了解 babel 的基本原理,這個微型 babel 的功能很單一也很雞肋,但是依然有400行程式碼,其實現細節與 babel 並不相同,因為我們省去了很多額外的驗證和資訊解析,因為單單一個相容現代 JavaScript 語法的 parser 就需要5000行程式碼,並不利於我們快速瞭解 babel 的基本實現,所以這個微型 babel可以說比較雞肋(因為除了展示之外沒啥用處),但是比較完整展示了 babel 的基本原理,你可以以此作為入門,在入門之後如果仍有興趣,可以閱讀:
1.程式碼解析
1.1 parser 概念
程式碼解析,也就是我們常說的 Parser, 用於將一段程式碼(文字)解析成一個資料結構.
例如這段 es6的程式碼
const add = (a, b) => a + b
複製程式碼
我們用 babel 解析後便是這種形式:
{
"type": "File",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"program": {
"type": "Program",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"sourceType": "module",
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 0
},
"end": {
"line": 1,
"column": 27
}
},
"declarations": [
{
"type": "VariableDeclarator",
"start": 6,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 27
}
},
"id": {
"type": "Identifier",
"start": 6,
"end": 9,
"loc": {
"start": {
"line": 1,
"column": 6
},
"end": {
"line": 1,
"column": 9
},
"identifierName": "add"
},
"name": "add"
},
"init": {
"type": "ArrowFunctionExpression",
"start": 12,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 12
},
"end": {
"line": 1,
"column": 27
}
},
"id": null,
"generator": false,
"expression": true,
"async": false,
"params": [
{
"type": "Identifier",
"start": 13,
"end": 14,
"loc": {
"start": {
"line": 1,
"column": 13
},
"end": {
"line": 1,
"column": 14
},
"identifierName": "a"
},
"name": "a"
},
{
"type": "Identifier",
"start": 16,
"end": 17,
"loc": {
"start": {
"line": 1,
"column": 16
},
"end": {
"line": 1,
"column": 17
},
"identifierName": "b"
},
"name": "b"
}
],
"body": {
"type": "BinaryExpression",
"start": 22,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 27
}
},
"left": {
"type": "Identifier",
"start": 22,
"end": 23,
"loc": {
"start": {
"line": 1,
"column": 22
},
"end": {
"line": 1,
"column": 23
},
"identifierName": "a"
},
"name": "a"
},
"operator": "+",
"right": {
"type": "Identifier",
"start": 26,
"end": 27,
"loc": {
"start": {
"line": 1,
"column": 26
},
"end": {
"line": 1,
"column": 27
},
"identifierName": "b"
},
"name": "b"
}
}
}
}
],
"kind": "const"
}
],
"directives": []
}
}
複製程式碼
我們以解析上面的 es6箭頭函式為目標,來寫一個簡單的 parser.
文字 ---> AST 的過程中有兩個關鍵步驟:
- 詞法分析: 將程式碼(字串)分割為token流,即語法單元成的陣列
- 語法分析: 分析token流(上面生成的陣列)並生成 AST
1.2 詞法分析(Tokenizer -- 詞法分析器)
要做詞法分析,首先我們需要明白在 JavaScript 中哪些屬於語法單元
- 數字:JavaScript 中的科學記數法以及普通陣列都屬於語法單元.
- 括號:『(』『)』只要出現,不管任何意義都算是語法單元
- 識別符號:連續字元,常見的有變數,常量(例如: null true),關鍵字(if break)等等
- 運算子:+、-、*、/等等
- 當然還有註釋,中括號等
在我們 parser 的過程中,應該換一個角度看待程式碼,我們平時工作用的程式碼.本質是就是字串或者一段文字,它沒有任何意義,是 JavaScript 引擎賦予了它意義,所以我們在解析過程中程式碼只是一段字串.
仍然以下面程式碼為例
const add = (a, b) => a + b
複製程式碼
我們期望的結果是類似這樣的
[
{ type: "identifier", value: "const" },
{ type: "whitespace", value: " " },
...
]
複製程式碼
那麼我們現在開始打造一個Tokenizer(詞法分析器)
// 詞法分析器,接收字串返回token陣列
export const tokenizer = (code) => {
// 儲存 token 的陣列
const tokens = [];
// 指標
let current = 0;
while (current < code.length) {
// 獲取指標指向的字元
const char = code[current];
// 我們先處理單字元的語法單元 類似於`;` `(` `)`等等這種
if (char === '(' || char === ')') {
tokens.push({
type: 'parens',
value: char,
});
current ++;
continue;
}
// 我們接著處理識別符號,識別符號一般為以字母、_、$開頭的連續字元
if (/[a-zA-Z\$\_]/.test(char)) {
let value = '';
value += char;
current ++;
// 如果是連續字那麼將其拼接在一起,隨後指標後移
while (/[a-zA-Z0-9\$\_]/.test(code[current]) && current < code.length) {
value += code[current];
current ++;
}
tokens.push({
type: 'identifier',
value,
});
continue;
}
// 處理空白字元
if (/\s/.test(char)) {
let value = '';
value += char;
current ++;
//道理同上
while (/\s]/.test(code[current]) && current < code.length) {
value += code[current];
current ++;
}
tokens.push({
type: 'whitespace',
value,
});
continue;
}
// 處理逗號分隔符
if (/,/.test(char)) {
tokens.push({
type: ',',
value: ',',
});
current ++;
continue;
}
// 處理運算子
if (/=|\+|>/.test(char)) {
let value = '';
value += char;
current ++;
while (/=|\+|>/.test(code[current])) {
value += code[current];
current ++;
}
// 當 = 後面有 > 時為箭頭函式而非運算子
if (value === '=>') {
tokens.push({
type: 'ArrowFunctionExpression',
value,
});
continue;
}
tokens.push({
type: 'operator',
value,
});
continue;
}
// 如果碰到我們詞法分析器以外的字元,則報錯
throw new TypeError('I dont know what this character is: ' + char);
}
return tokens;
};
複製程式碼
那麼我們基本的詞法分析器就打造完成,因為只針對這一個es6函式,所以沒有做額外的工作(額外的工作量會非常龐大).
const result = tokenizer('const add = (a, b) => a + b')
console.log(result);
/**
[ { type: 'identifier', value: 'const' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'add' },
{ type: 'whitespace', value: ' ' },
{ type: 'operator', value: '=' },
{ type: 'whitespace', value: ' ' },
{ type: 'parens', value: '(' },
{ type: 'identifier', value: 'a' },
{ type: ',', value: ',' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'b' },
{ type: 'parens', value: ')' },
{ type: 'whitespace', value: ' ' },
{ type: 'ArrowFunctionExpression', value: '=>' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'a' },
{ type: 'whitespace', value: ' ' },
{ type: 'operator', value: '+' },
{ type: 'whitespace', value: ' ' },
{ type: 'identifier', value: 'b' } ]
**/
複製程式碼
1.3 語法分析
語法分析要比詞法分析複雜得多,因為我們接下來的是示意程式碼,所以做了很多“武斷”的判斷來省略程式碼,即使這樣也是整個微型 babel 中程式碼量最多的.
語法分析之所以複雜,是因為要分析各種語法的可能性,需要開發者根據token流(上一節我們生成的 token 陣列)提供的資訊來分析出程式碼之間的邏輯關係,只有經過詞法分析 token 流才能成為有結構的抽象語法樹.
做語法分析最好依照標準,大多數 JavaScript Parser 都遵循estree規範
由於標準內容很多,感興趣的可以去閱讀,我們目前只介紹幾個比較重要的標準:
語句(Statements): 語句是 JavaScript 中非常常見的語法,我們常見的迴圈、if 判斷、異常處理語句、with 語句等等都屬於語句
// 典型的for 迴圈語句
for (var i = 0; i < 7; i++) {
console.log(i);
}
複製程式碼
表示式(Expressions): 表示式是一組程式碼的集合,它返回一個值,表示式是另一個十分常見的語法,函式表示式就是一種典型的表示式,如果你不理解什麼是表示式, MDN上有很詳細的解釋.
// 函式表示式
var add = function(a, b) {
return a + b
}
複製程式碼
宣告(Declarations): 宣告分為變數宣告和函式宣告,表示式(Expressions)中的函式表示式的例子用宣告的寫法就是下面這樣.
// 函式宣告
function add(a, b) {
return a + b
}
複製程式碼
你可能有點糊塗,為了理清其中的關係,我們就下面的程式碼為例來解讀
// 函式表示式
var add = function(a, b) {
return a + b
}
複製程式碼
首先這段程式碼的整體本質是是一個變數宣告(VariableDeclarator):
而變數被宣告為一個函式表示式(FunctionExpression):
函式表示式中的大括號在內的為塊狀語句(BlockStatement):
塊狀語句內 return 的部分是返回語句(ReturnStatement):
而 return 的其實是一個二元運算子或者叫二元表示式(BinaryExpression):
上面提到的這些有些屬於表示式,有些屬於宣告也有些屬於語句,當然還有更多我們沒提到的,它們被語法分析之後被叫做AST(抽象語法樹).
我們做語法分析的時候思路也是類似的,要分析哪一層的 token 到底屬於表示式或者說語句,如果是語句那麼是塊狀語句(BlockStatement)還是Loops,如果是 Loops 那麼屬於while 迴圈(WhileStatement)還是for 迴圈(ForStatement)等等,其中甚至難免要考慮作用域的問題,因此語法分析的複雜也體現在此.
const parser = tokens => {
// 宣告一個全時指標,它會一直存在
let current = -1;
// 宣告一個暫存棧,用於存放臨時指標
const tem = [];
// 指標指向的當前token
let token = tokens[current];
const parseDeclarations = () => {
// 暫存當前指標
setTem();
// 指標後移
next();
// 如果字元為'const'可見是一個宣告
if (token.type === 'identifier' && token.value === 'const') {
const declarations = {
type: 'VariableDeclaration',
kind: token.value
};
next();
// const 後面要跟變數的,如果不是則報錯
if (token.type !== 'identifier') {
throw new Error('Expected Variable after const');
}
// 我們獲取到了變數名稱
declarations.identifierName = token.value;
next();
// 如果跟著 '=' 那麼後面應該是個表示式或者常量之類的,額外判斷的程式碼就忽略了,直接解析函式表示式
if (token.type === 'operator' && token.value === '=') {
declarations.init = parseFunctionExpression();
}
return declarations;
}
};
const parseFunctionExpression = () => {
next();
let init;
// 如果 '=' 後面跟著括號或者字元那基本判斷是一個表示式
if (
(token.type === 'parens' && token.value === '(') ||
token.type === 'identifier'
) {
setTem();
next();
while (token.type === 'identifier' || token.type === ',') {
next();
}
// 如果括號後跟著箭頭,那麼判斷是箭頭函式表示式
if (token.type === 'parens' && token.value === ')') {
next();
if (token.type === 'ArrowFunctionExpression') {
init = {
type: 'ArrowFunctionExpression',
params: [],
body: {}
};
backTem();
// 解析箭頭函式的引數
init.params = parseParams();
// 解析箭頭函式的函式主體
init.body = parseExpression();
} else {
backTem();
}
}
}
return init;
};
const parseParams = () => {
const params = [];
if (token.type === 'parens' && token.value === '(') {
next();
while (token.type !== 'parens' && token.value !== ')') {
if (token.type === 'identifier') {
params.push({
type: token.type,
identifierName: token.value
});
}
next();
}
}
return params;
};
const parseExpression = () => {
next();
let body;
while (token.type === 'ArrowFunctionExpression') {
next();
}
// 如果以(開頭或者變數開頭說明不是 BlockStatement,我們以二元表示式來解析
if (token.type === 'identifier') {
body = {
type: 'BinaryExpression',
left: {
type: 'identifier',
identifierName: token.value
},
operator: '',
right: {
type: '',
identifierName: ''
}
};
next();
if (token.type === 'operator') {
body.operator = token.value;
}
next();
if (token.type === 'identifier') {
body.right = {
type: 'identifier',
identifierName: token.value
};
}
}
return body;
};
// 指標後移的函式
const next = () => {
do {
++current;
token = tokens[current]
? tokens[current]
: { type: 'eof', value: '' };
} while (token.type === 'whitespace');
};
// 指標暫存的函式
const setTem = () => {
tem.push(current);
};
// 指標回退的函式
const backTem = () => {
current = tem.pop();
token = tokens[current];
};
const ast = {
type: 'Program',
body: []
};
while (current < tokens.length) {
const statement = parseDeclarations();
if (!statement) {
break;
}
ast.body.push(statement);
}
return ast;
};
複製程式碼
至此我們暴力 parser 了token 流,最終得到了簡陋的抽象語法樹:
{
"type": "Program",
"body": [
{
"type": "VariableDeclaration",
"identifierName": "add",
"init": {
"type": "ArrowFunctionExpression",
"params": [
{
"type": "identifier",
"identifierName": "a"
},
{
"type": "identifier",
"identifierName": "b"
}
],
"body": {
"type": "BinaryExpression",
"left": {
"type": "identifier",
"identifierName": "a"
},
"operator": "+",
"right": {
"type": "identifier",
"identifierName": "b"
}
}
}
}
]
}
複製程式碼
2 程式碼轉換
2.1 如何轉換程式碼?
在 Babel 中我們使用者最常使用的地方就是程式碼轉換,大家常用的 Babel 外掛就是定義程式碼轉換規則而生的,而程式碼解析和生成這一頭一尾都主要是 Babel 負責。
比如我們要用 babel 做一個React 轉小程式的轉換器,babel工作流程的粗略情況是這樣的:
- babel 將 React 程式碼解析為抽象語法樹
- 開發者利用 babel 外掛定義轉換規則,根據原本的抽象語法樹生成一個符合小程式規則的新抽象語法樹
- babel 則根據新的抽象語法樹生成程式碼,此時的程式碼就是符合小程式規則的新程式碼
例如 Taro就是用 babel 完成的小程式語法轉換.
到這裡大家就明白了,我們轉換程式碼的關鍵就是根據當前的抽象語法樹,以我們定義的規則生成新的抽象語法樹,轉換的過程就是生成新抽象語法樹的過程.
2.2 遍歷抽象語法樹(實現遍歷器traverser)
抽象語法樹是一個樹狀資料結構,我們要生成新語法樹,那麼一定需要訪問 AST 上的節點,因此我們需要一個工具來遍歷抽象語法樹的節點.
const traverser = (ast, visitor) => {
// 如果節點是陣列那麼遍歷陣列
const traverseArray = (array, parent) => {
array.forEach((child) => {
traverseNode(child, parent);
});
};
// 遍歷 ast 節點
const traverseNode = (node, parent) => {
const method = visitor[node.type];
if (method) {
method(node, parent);
}
switch (node.type) {
case 'Program':
traverseArray(node.body, node);
break;
case 'VariableDeclaration':
traverseArray(node.init.params, node.init);
break;
case 'identifier':
break;
default:
throw new TypeError(node.type);
}
};
traverseNode(ast, null);
};
複製程式碼
2.3 轉換程式碼(實現轉換器transformer)
我們要轉換的程式碼const add = (a, b) => a + b
其實是個變數宣告,按理來講我們要轉換為es5的程式碼也應該是個變數宣告,比如這種:
var add = function(a, b) {
return a + b
}
複製程式碼
當然也可以不按規則,直接生成一個函式宣告,像這樣:
function add(a, b) {
return a + b
}
複製程式碼
這次我們把程式碼轉換為一個es5的函式宣告
我們之前的遍歷器traverser
接收兩個引數,一個是 ast 節點物件,一個是 visitor,visitor本質是掛載不同方法的 JavaScript 物件,visitor 也叫做訪問者,顧名思義它會訪問 ast 上每個節點,然後根據針對不同節點用相應的方法做出不同的轉換.
const transformer = (ast) => {
// 新 ast
const newAst = {
type: 'Program',
body: []
};
// 在老 ast 上加一個指標指向新 ast
ast._context = newAst.body;
traverser(ast, {
// 對於變數宣告的處理方法
VariableDeclaration: (node, parent) => {
let functionDeclaration = {
params: []
};
if (node.init.type === 'ArrowFunctionExpression') {
functionDeclaration.type = 'FunctionDeclaration';
functionDeclaration.identifierName = node.identifierName;
}
if (node.init.body.type === 'BinaryExpression') {
functionDeclaration.body = {
type: 'BlockStatement',
body: [{
type: 'ReturnStatement',
argument: node.init.body
}],
};
}
parent._context.push(functionDeclaration);
},
//對於字元的處理方法
identifier: (node, parent) => {
if (parent.type === 'ArrowFunctionExpression') {
// 忽略我這暴力的操作....領略大意即可..
ast._context[0].params.push({
type: 'identifier',
identifierName: node.identifierName
});
}
}
});
return newAst;
};
複製程式碼
3 生成程式碼(實現生成器generator)
我們之前提到過,生成程式碼這一步實際上是根據我們轉換後的抽象語法樹來生成新的程式碼,我們會實現一個函式, 他接受一個物件( ast),通過遞迴生成最終的程式碼
const generator = (node) => {
switch (node.type) {
// 如果是 `Program` 結點,那麼我們會遍歷它的 `body` 屬性中的每一個結點,並且遞迴地
// 對這些結點再次呼叫 codeGenerator,再把結果列印進入新的一行中。
case 'Program':
return node.body.map(generator)
.join('\n');
// 如果是FunctionDeclaration我們分別遍歷呼叫其引數陣列以及呼叫其 body 的屬性
case 'FunctionDeclaration':
return 'function' + ' ' + node.identifierName + '(' + node.params.map(generator) + ')' + ' ' + generator(node.body);
// 對於 `Identifiers` 我們只是返回 `node` 的 identifierName
case 'identifier':
return node.identifierName;
// 如果是BlockStatement我們遍歷呼叫其body陣列
case 'BlockStatement':
return '{' + node.body.map(generator) + '}';
// 如果是ReturnStatement我們呼叫其 argument 的屬性
case 'ReturnStatement':
return 'return' + ' ' + generator(node.argument);
// 如果是ReturnStatement我們呼叫其左右節點並拼接
case 'BinaryExpression':
return generator(node.left) + ' ' + node.operator + ' ' + generator(node.right);
// 沒有符合的則報錯
default:
throw new TypeError(node.type);
}
};
複製程式碼
至此我們完成了一個簡陋的微型 babel,我們開始試驗:
const compiler = (input) => {
const tokens = tokenizer(input);
const ast = parser(tokens);
const newAst = transformer(ast);
const output = generator(newAst);
return output;
};
const str = 'const add = (a, b) => a + b';
const result = compiler(str);
console.log(result);
// function add(a,b) {return a + b}
複製程式碼
我們成功地將一個es6的箭頭函式轉換為es5的function函式.
最後
我們可以通過這個微型 babel 瞭解 babel 的工作原理,如果讓你對編譯原理產生興趣並去深入那是更好的, babel集合包 是有數十萬行程式碼的巨大工程,我們用區區幾百行程式碼只能展示其最基本的原理,程式碼有很多不合理之處,如果想真正的瞭解 babel 歡迎閱讀器原始碼.
前端可以利用編譯原理相關的東西還有很多,除了我們常見的es6轉換工具 babel,程式碼檢測的 eslint等等,我們還可以:
- 小程式多端轉義 Taro
- 小程式熱更新js 直譯器
- babel與錯誤監控瀏覽器端 JavaScript 異常監控
- 模板引擎
- css 預處理後處理等等
- ...
這篇文章受the-super-tiny-compiler啟發而來.