實現一個簡單的 JavaScript 編譯器

taokexia發表於2019-02-22

Babel 是目前最常用的 JavaScript 編譯器。能夠編譯 JS 程式碼,使得程式碼能夠正常的在舊版本的瀏覽器上面執行;還能夠轉化 JSX 語法,使得 react 寫的程式碼能夠正常執行。

下面,按照編譯原理來實現一個簡單的 JS 程式碼編譯器,實現把 ES6 程式碼轉化成 ES5,以充分了解 Babel 執行原理。

let a = 1
複製程式碼

轉化後

var a = 1
複製程式碼

編譯原理

編譯器的編譯原理大多分為三個階段: 解析、轉換以及程式碼生成

  • 解析(Parsing): 將原始程式碼轉化成 AST 抽象樹
  • 轉換(Transformation): 對 AST 抽象樹進行處理,變化結構
  • 程式碼生成(Code Generation): 把處理後的 AST 抽象樹轉化成程式碼

解析

編譯前,首先要對程式碼進行解析,解析分為兩個階段 詞義分析(Lexical Analysis)語法分析(Syntactic Analysis)

詞義分析

詞義分析是接收原始程式碼進行分詞,最後生成 token。

例如:
let a = 1

詞義分析後結果為:

[ { "type": "Keyword",    "value": "let" },
  { "type": "Identifier", "value": "a"   },
  { "type": "Punctuator", "value": "="   },
  { "type": "Numeric",    "value": "1"   } ]
複製程式碼

詞義分析器函式為:

// 解析程式碼,最後返回 tokens
function tokenizer(input) {
  // 記錄當前解析到詞的位置
  var current = 0
  // tokens 用來儲存我們解析的 token
  var tokens = []

  // 利用迴圈進行解析
  while (current < input.length) {
    // 提取出當前要解析的字元
    var char = input[current]

    // 處理符號: 檢查是否是符號
    var PUNCTUATOR = /[`~!@#$%^&*()_\-+=<>?:"{}|,.\/;'\\[\]·~!@#¥%……&*()——\-+={}|《》?:“”【】、;‘’,。、]/im
    if (PUNCTUATOR.test(char)) {
      // 建立變數用於儲存匹配的符號
      var punctuators = char
      // 判斷是否是箭頭函式的符號
      if(char === '=' && input[current+1] === '>') {
        punctuators += input[++current]
      }
      current++;
      // 最後把資料更新到 tokens 中
      tokens.push({
        type: 'Punctuator',
        value: punctuators
      })
      // 進入下一次迴圈
      continue
    }

    // 處理空格: 如果是空格,則直接進入下一個迴圈
    var WHITESPACE = /\s/
    if (WHITESPACE.test(char)) {
      current++
      continue
    }

    // 處理數字: 檢查是否是數字
    var NUMBERS = /[0-9]/
    if (NUMBERS.test(char)) {
      // 建立變數用於儲存匹配的數字
      var number = ''
      // // 迴圈遍歷接下來的字元,直到下一個字元不是數字為止
      while (NUMBERS.test(char)) {
        number += char
        char = input[++current]
      }
      // 最後把資料更新到 tokens 中
      tokens.push({
        type: 'Numeric',
        value: number
      })
      // 進入下一次迴圈
      continue
    }

    // 處理字元: 檢查是否是字元
    var LETTERS = /[a-z]/i
    if (LETTERS.test(char)) {
      var value = ''

      // 用一個迴圈遍歷所有的字母,把它們存入 value 中。
      while (LETTERS.test(char)) {
        value += char
        char = input[++current]
      }
      // 判斷當前字串是否是關鍵字
      KEYWORD = /function|var|return|let|const|if|for/
      if(KEYWORD.test(value)) {
        // 標記關鍵字
        tokens.push({
          type: 'Keyword',
          value: value
        })
      } else {
        // 標記變數
        tokens.push({
          type: 'Identifier',
          value: value
        })
      }
      // 進入下一次迴圈
      continue
    }
    // 最後如果我們沒有匹配上任何型別的 token,那麼我們丟擲一個錯誤。
    throw new TypeError('I dont know what this character is: ' + char)
  }
  // 詞法分析器的最後我們返回 tokens 陣列。
  return tokens
}
複製程式碼

語法分析

詞義分析後,接下來是語法分析, 接收詞義分析的 tokens, 然後分析之間內部關係,最終生成抽象語法樹(Abstract Syntax Tree, 縮寫為AST)

例如:

[ { "type": "Keyword",    "value": "let" },
  { "type": "Identifier", "value": "a"   },
  { "type": "Punctuator", "value": "="   },
  { "type": "Numeric",    "value": "1"   } ]
複製程式碼

語法分析後結果為:

{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "let"
        }
    ],
    "sourceType": "script"
}
複製程式碼

解析函式為

// 語法解析函式,接收 tokens 作為引數
function parser(tokens) {
  // 記錄當前解析到詞的位置
  var current = 0

  // 通過遍歷來解析 token節點,定義 walk 函式
  function walk() {
    // 從當前 token 開始解析
    var token = tokens[current]
    // 獲取下一個節點的 token
    var nextToken = tokens[current + 1]

    // 對於不同型別的結點,對應的處理方法也不同
    // 檢查是不是數字型別
    if (token.type === 'Numeric') {
      // 如果是,current 自增。
      current++
      // 然後我們會返回一個新的 AST 結點
      return {
        type: 'Literal',
        value: Number(token.value),
        row: token.value
      }
    }

    // 檢查是不是變數型別
    if (token.type === 'Identifier') {
      // 如果是,current 自增。
      current++;
      // 然後我們會返回一個新的 AST 結點
      return {
        type: 'Identifier',
        name: token.value,
      };
    }

    // 檢查是不是運算子型別
    if (token.type === 'Punctuator') {
      // 如果是,current 自增。
      current++;
      // 判斷運算子型別,根據型別返回新的 AST 節點
      if(/[\+\-\*/]/im.test(token.value))
        return {
          type: 'BinaryExpression',
          operator: token.value,
        }
      if(/\=/.test(token.value))
        return {
          type: 'AssignmentExpression',
          operator: token.value
        }
    }

    // 檢查是不是關鍵字
    if ( token.type === 'Keyword') {
      var value = token.value
      // 檢查是不是定義語句
      if( value === 'var' || value === 'let' || value === 'const' ) {
        current++;
        // 獲取定義的變數
        var variable = walk()
        // 判斷是否是賦值符號
        var equal = walk()
        var rightVar
        if(equal.operator === '=') {
          // 獲取所賦予的值
          rightVar = walk()
        } else {
          // 不是賦值符號,說明只是定義變數
          rightVar = null
          current--
        }
        // 定義宣告
        var declaration = {
          type: 'VariableDeclarator',
          id: variable, // 定義的變數
          init: rightVar // 賦予的值
        }
        // 定義要返回的節點
        return {
          type: 'VariableDeclaration',
          declarations: [declaration],
          kind: value,
        };
      }
    }

    // 遇到了一個型別未知的結點,就丟擲一個錯誤。
    throw new TypeError(token.type);
  }
  // 現在,我們建立 AST,根結點是一個型別為 `Program` 的結點。
  var ast = {
    type: 'Program',
    body: [],
    sourceType: "script"
  };

  // 開始 walk 函式,把結點放入 ast.body 中。
  while (current < tokens.length) {
    ast.body.push(walk());
  }

  // 最後我們的語法分析器返回 AST 
  return ast;
}
複製程式碼

轉換

編譯器的下一步就是轉換。對 AST 抽象樹進行處理,可以在同語言間進行轉換,也可以轉換成一種全新的語言(參考 JSX 轉換)

轉換 AST 的時候,我們可以新增、移動、替代、刪除 AST抽象樹裡的節點。

轉化前:

{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "let"
        }
    ],
    "sourceType": "script"
}
複製程式碼

轉化後

{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "var"
        }
    ],
    "sourceType": "script"
}
複製程式碼

遍歷器

為了修改 AST 抽象樹,首先要對節點進行遍歷,採用深度遍歷的方法。遍歷函式:

// 所以我們定義一個遍歷器,它有兩個引數,AST 和 vistor
// visitor 定義轉化函式
function traverser(ast, visitor) {

  // 遍歷樹中每個節點,呼叫 traverseNode
  function traverseArray(array, parent) {
    if(typeof array.forEach === 'function')
      array.forEach(function(child) {
        traverseNode(child, parent);
      });
  }

  // 處理 ast 節點的函式, 使用 visitor 定義的轉換函式進行轉換
  function traverseNode(node, parent) {
    // 首先看看 visitor 中有沒有對應 type 的處理函式。
    var method = visitor[node.type]
    // 如果有,參入引數
    if (method) {
      method(node, parent)
    }

    // 下面對每一個不同型別的結點分開處理。
    switch (node.type) {

      // 從頂層的 Program 開始
      case 'Program':
        traverseArray(node.body, node)
        break
      // 如果不需要轉換,則直接退出
      case 'VariableDeclaration':
      case 'VariableDeclarator':
      case 'AssignmentExpression':
      case 'Identifier':
      case 'Literal':
        break

      // 同樣,如果不能識別當前的結點,那麼就丟擲一個錯誤。
      default:
        throw new TypeError(node.type)
    }
  }
  // 最後我們對 AST 呼叫 traverseNode,開始遍歷。注意 AST 並沒有父結點。
  traverseNode(ast, null)
}
複製程式碼

轉換器

轉換器接用於遍歷過程中轉換資料,他接收之前構建好的 AST樹,然後把它和 visitor 傳遞進入我們的遍歷器中 ,最後得到一個新的 AST 抽象樹。

// 定義我們的轉換器函式,接收 AST 作為引數
function transformer(ast) {
  // 建立新的 ast 抽象樹
  var newAst = {
    type: 'Program',
    body: [],
    sourceType: "script"
  };

  // 下面是個程式碼技巧,在父結點上定義一個屬性 context(上下文),之後,就可以把結點放入他們父結點的 context 中。
  ast._context = newAst.body

  // 我們把 AST 和 visitor 函式傳入遍歷器
  traverser(ast, {
    // 把 VariableDeclaration kind 屬性進行轉換
    VariableDeclaration: function(node, parent) {
      var variableDeclaration = {
        type: 'VariableDeclaration',
        declarations: node.declarations,
        kind: "var"
      };
      // 把新的 VariableDeclaration 放入到 context 中。
      parent._context.push(variableDeclaration)
    }
  });
  // 最後返回建立好的新 AST。
  return newAst
}
複製程式碼

程式碼生成

最後一步就是程式碼生成了,這個階段做的事情有時候會和轉換(transformation)重疊,但是程式碼生成最主要的部分還是根據 AST 來輸出程式碼。

程式碼生成器會遞迴地呼叫它自己,把 AST 中的每個結點列印到一個很大的字串中。

function codeGenerator(node) {
  // 對於不同型別的結點分開處理
  switch (node.type) {
    // 如果是 Program 結點,那麼我們會遍歷它的 body 屬性中的每一個結點。
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n')

    // VariableDeclaration 結點
    case 'VariableDeclaration':
      return (
        node.kind + ' ' + codeGenerator(node.declarations)
      )

    // VariableDeclarator 節點
    case 'VariableDeclarator':
      return (
        codeGenerator(node.id) + ' = ' + 
        codeGenerator(node.init)
      );

    // 處理變數
    case 'Identifier':
      return node.name;

    // 處理數值
    case 'Literal':
      return node.value;

    // 如果我們不能識別這個結點,那麼丟擲一個錯誤。
    default:
      throw new TypeError(node.type);
  }
}
複製程式碼

轉化前:

{
    "type": "Program",
    "body": [
        {
            "type": "VariableDeclaration",
            "declarations": [
                {
                    "type": "VariableDeclarator",
                    "id": {
                        "type": "Identifier",
                        "name": "a"
                    },
                    "init": {
                        "type": "Literal",
                        "value": 1,
                        "raw": "1"
                    }
                }
            ],
            "kind": "var"
        }
    ],
    "sourceType": "script"
}
複製程式碼

轉化後

var a = 1
複製程式碼

經過實踐,我們按照 Babel 原理實現了一個簡單的 JavaScript 編譯器。 現在可以接著擴充套件這些程式碼,實現自己的編譯器了!!!

程式碼地址

參考資料

相關文章