程序員面試官

面試官: 聊一聊Babel

 

前言

Babel 是現代 JavaScript 語法轉換器,幾乎在任何現代前端項目中都能看到他的身影,其背後的原理對於大部分開發者還屬於黑盒,不過 Babel 做爲一個工具真的有了解背後原理的必要嗎?javascript

若是隻是 Babel 可能真沒有必要,問題是其背後的原理在咱們開發中應用過於普遍了,包括不限於: eslint jshint stylelint css-in-js prettier jsx vue-template uglify-js postcss less 等等等等,從模板到代碼檢測,從混淆壓縮到代碼轉換,甚至編輯器的代碼高亮都與之息息相關.css

若是有興趣就能夠搞一些黑魔法。html

前置

Babel 大概分爲三大部分:前端

  • 解析: 將代碼(其實就是字符串)轉換成 AST( 抽象語法樹)
  • 轉換: 訪問 AST 的節點進行變換操做生成新的 AST
  • 生成: 以新的 AST 爲基礎生成代碼

咱們主要經過打造一個微型 babel 來了解 babel 的基本原理,這個微型 babel 的功能很單一也很雞肋,可是依然有400行代碼,其實現細節與 babel 並不相同,由於咱們省去了不少額外的驗證和信息解析,由於單單一個兼容現代 JavaScript 語法的 parser 就須要5000行代碼,並不利於咱們快速瞭解 babel 的基本實現,因此這個微型 babel能夠說比較雞肋(由於除了展現以外沒啥用處),可是比較完整展現了 babel 的基本原理,你能夠以此做爲入門,在入門以後若是仍有興趣,能夠閱讀:vue

  • estree規範
  • acorn: 輕量級現代 JavaScript 解析器, babel 最初就是基於此項目

代碼解析

parser 概念

代碼解析,也就是咱們常說的 Parser, 用於將一段代碼(文本)解析成一個數據結構.java

例如這段 es6的代碼node

const add = (a, b) => a + b

咱們用 babel 解析後即是這種形式:git

{
  "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.es6

文本 ---> AST 的過程當中有兩個關鍵步驟:github

  • 詞法分析: 將代碼(字符串)分割爲token流,即語法單元成的數組
  • 語法分析: 分析token流(上面生成的數組)並生成 AST

詞法分析(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" } } } } ] } 

代碼轉換

如何轉換代碼?

在 Babel 中咱們使用者最常使用的地方就是代碼轉換,你們經常使用的 Babel 插件就是定義代碼轉換規則而生的,而代碼解析和生成這一頭一尾都主要是 Babel 負責。

好比咱們要用 babel 作一個React 轉小程序的轉換器,babel工做流程的粗略狀況是這樣的:

  1. babel 將 React 代碼解析爲抽象語法樹
  2. 開發者利用 babel 插件定義轉換規則,根據本來的抽象語法樹生成一個符合小程序規則的新抽象語法樹
  3. babel 則根據新的抽象語法樹生成代碼,此時的代碼就是符合小程序規則的新代碼

例如 Taro就是用 babel 完成的小程序語法轉換.

到這裏你們就明白了,咱們轉換代碼的關鍵就是根據當前的抽象語法樹,以咱們定義的規則生成新的抽象語法樹,轉換的過程就是生成新抽象語法樹的過程.

遍歷抽象語法樹(實現遍歷器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); };

轉換代碼(實現轉換器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; }; 

生成代碼(實現生成器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等等,咱們還能夠:

  1. 小程序多端轉義Taro
  2. 小程序熱更新js 解釋器
  3. babel與錯誤監控瀏覽器端 JavaScript 異常監控
  4. 模板引擎
  5. css 預處理後處理等等
  6. ...
相關文章
相關標籤/搜索