使用JavaScript實現一個簡單的編譯器


在前端開發中也會或多或少接觸到一些與編譯相關的內容,常見的有

  • 將ES6、7代碼編譯成ES5的代碼
  • 將SCSS、LESS代碼轉換成瀏覽器支持的CSS代碼
  • 通過uglifyjs、uglifycss等工具壓縮代碼
  • 將TypeScript代碼轉換成JavaScript代碼
  • Vue模板語法轉換成render函數、JSX語法轉換成JS代碼

盡管社區的工具如bable*-loader已經幫我們完成了上面的所有工作,我們不用關心編譯的過程,甚至也很少有人關注輸出的代碼,但是了解編譯原理還是很有必要的,這篇文章主要用來記錄我在學習編譯原理時整理的一些筆記。

本文包含大量示例代碼,跳過部分代碼並不影響閱讀重要,因此可酌情忽略,附:源碼地址

本文主要參考下面兩篇文章

寫在前面

在了解編譯器的相關概念之前,讓我們先確定一下到底是稱呼"編譯器"還是"解釋器"。

程序主要有兩種運行方式:靜態編譯與動態解釋。靜態編譯的程序在執行前全部被翻譯為機器碼,通常將這種類型稱為AOT (Ahead of time)即 “提前編譯”;而解釋執行的則是一句一句邊翻譯邊運行,通常將這種類型稱為JIT(Just-in-time)即“即時編譯”。

從靜態編譯與動態解釋這兩種程序運行的方式,可以引申出兩個工具

  • 解釋器:直接執行用編程語言編寫的指令的程序,即JIT運行時的工具
  • 編譯器:把源代碼轉換成(翻譯)低級語言的程序,即AOT運行時的工具

實際上,我認為初學時不需要糾結於這些概念,本文的主要目的是實現將一個簡單的工具,它的功能是:將dart函數命名參數調用形式

sayHello(name: "shymean", msg: 'hello'); 復制代碼

轉換為javascript函數參數調用

sayHello({name: 'shymean', msg: 'hello'}) 復制代碼

如果只是為了輸出JavaScript代碼,可以把這個工具看做是編譯器,因為它將一種代碼語言編譯成了另外一種代碼語言;如果在工具后面在添加一行eval,這個工具就變成了解釋器,因為它可以通過JavaScript直接“運行”Dart代碼。

所以,簡而言之,這篇文章不關注我們實現的到底是編譯器還是解釋器,統稱為編譯器即可。

現在回到我們要實現的這個編譯器,看起來只要加一對大括號就行了嗎?不,我們將學習編譯原理,並了解編譯器的基本工作流程。

編譯器的工作流程

大多數編譯器可以分成三個階段:解析(Parsing),轉換(Transformation)以及代碼生成(Code Generation)

  • 解析是將源代碼轉換為一種更抽象的表達方式,一般將結果稱為AST抽象語法樹
  • 轉換是對AST做一些處理,讓他能做到編譯器預期他做到的事情
  • 代碼生成器接收AST轉換之后的數據結構,然后把它轉換成新的代碼。

在vue的源碼compiler實現中,可以查看模板編譯相關的邏輯

export const createCompiler = createCompilerCreator(function baseCompile ( template: string, options: CompilerOptions ): CompiledResult { const ast = parse(template.trim(), options) if (options.optimize !== false) { optimize(ast, options) } const code = generate(ast, options) return { ast, render: code.render, staticRenderFns: code.staticRenderFns } }) 復制代碼

可以看見其主要的工作跟上面提到的編譯器工作流程基本一致

  • parse,將template模板解析成ast,主要是解析標簽和上面的屬性、指令等內容
  • optimize,主要是標記ast的靜態節點,優化虛擬DOM樹,方便后期diff優化算法
  • generate,通過ast生成render函數

接下來我們逐個學習編譯器的每一個流程。

解析獲取AST

解析一般來說會分成兩個階段:詞法分析和語法分析

  • 詞法分析主要是拆分原始代碼,並將其分割成一些Token,token由一些代碼語句的碎片組成,可以是數字、標簽、運算符等
  • 語法分析接收詞法分析生成的token,然后把他們轉換成一種抽象的表示,這種表示描述了代碼語句中每一個片段以及他們之間的關系,這被稱為AST抽象語法樹,AST是一個嵌套很深的對象,用一種更容易處理的方式代表了代碼本身

解析階段最主要的目的就是從源碼中獲取所需的信息,但是詞法分析和語法分析算是編譯器中十分繁瑣和無趣的"臟活",下面我們通過兩個例子來理解解析的概念和實現。

通過正則拆分文本

這是之前曾做過一個功能,將一段txt文本解析成對話小說,文本內容大致類似於下面結構(此外還有如圖片、分支選項等結構,這里暫且省略)

[作者]shymean
[簡介]測試測試測試測試

這里是背景旁白~

【小明】
How are you?

[小花]
I'm fine, 3Q you & you? 復制代碼

需要將其解析成大致如下JSON結構,方便后續提交到服務端

{ author: 'shymean',
  abstract: '測試測試測試測試',
  nodes:
   [ { type: 10, content: '這里是背景旁白~' },
     { name: '小明', content: 'How are you?', type: 0 },
     { name: '小花', content: 'I\'m fine, 3Q you & you?', type: 0 } ] }
復制代碼

由於整個文檔結構都是按約定編寫的,因此可以通過正則等方式提取關鍵信息。接下來是整個功能的簡單實現,通過這個功能,可以大致了解文本拆分的邏輯

function parser(input) { let [header, ...content] = input.split(/\r?\n\s*?\r?\n/); let { author, abstract } = parseHeader(header); let body = getNode(content); return { author, abstract, nodes: body }; // 拆分頭部信息 function parseHeader(head) { let res = null; let total = []; const reHeadInfo = /[\[【[].*?[\]】]](.*)\r?\n?/g; while ((res = reHeadInfo.exec(head))) { total.push(res[1]); } let [author, abstract] = total; return { author, abstract }; } // 獲取節點列表 function parseContent(content) { const reNode = /[\[【[](.*?)[\]】]].*(?=\r?\n)\r?\n([^]*)/; return content.map(item => { let res = reNode.exec(item); if (res) { // 對話 let name = res[1].trim(); let chatContent = res[2].trim(); return { name: name, content: chatContent, type: 0 }; } else { content = item.trim(); return { type: 10, content }; } }); } } 復制代碼

可以看見,我們可以使用編譯器語言支持的正則來進行拆分,Hexo解析markdown文本並輸出靜態html文件,大致也是同樣的原理。當然,直接遍歷源碼文本也是可以的,接下來我們來實現簡單的詞法分析

通過遍歷字符串拆分文本

以下面一段簡單的JS代碼為例

var a = 100; var b = "hello"; 復制代碼

我們需要把這段代碼里面的兩個變量聲明語句拆分出來,這里我們采用遍歷源碼文本的方法來實現

function isReserveWords(name) { const reserveWords = ["var"]; // 假設這里只有var一個關鍵字 return reserveWords.includes(name); } function tokenizer(input) { let tokens = []; let current = 0; while (current < input.length) { let char = input[current]; if (char === "=" || char===';') { tokens.push({ type: "symbol", value: char }); current++; continue; } // 去除空格 let WHITESPACE = /\s/; if (WHITESPACE.test(char)) { current++; continue; } let NUMBERS = /[0-9]/; if (NUMBERS.test(char)) { let value = ""; while (NUMBERS.test(char)) { value += char; char = input[++current]; } tokens.push({ type: "number", value }); continue; } // 解析字符串,這里只處理了雙引號的字符串 if (char === '"') { let value = ""; char = input[++current]; while (char !== '"') { value += char; char = input[++current]; } current++; tokens.push({ type: "string", value }); continue; } // 處理函數、變量名,除開上面的字面量后,只有保留字和函數變量名了 let LETTERS = /[a-z]/i; if (LETTERS.test(char)) { let value = ""; while (LETTERS.test(char)) { value += char; char = input[++current]; } if (isReserveWords(value)) { tokens.push({ type: "reserveWords", value }); } else { tokens.push({ type: "name", value }); } continue; } throw new TypeError("I dont know what this character is: " + char); } return tokens; } let code = `var a = 100; var b = "hello";`; let tokens = tokenizer(code); console.log(tokens); // 從源碼中解析得到tokens數組 復制代碼

可見對於某個符號或者某個連續字符串,都是由我們在解析過程中自己定義的,語言越復雜,詞法分析時需要判斷的情況越多~

將token轉換成AST

這里引用了怎樣寫一個解釋器這篇文章里面的圖。

我們可以把(* (+ 1 2) (+ 3 4))這樣的表達式拆分成一個tokens數組,然后我們可以遍歷這個數組,將其轉換成一個AST。下面是該表達式對應的AST結構

 

 

此外還可以參考這個在線示例,將tokens轉換成AST是一個比較復雜的過程,在本文實現的編譯器中有比較粗略的實現,這里不再贅述,其主要邏輯是通過遍歷tokens數組,將不同的token轉換成對應類型的節點,然后構造成AST樹結構。

遍歷AST

雖然AST不一定是二叉樹,但是了解二叉樹的遍歷方式還有有一些幫助的。二叉樹常見的兩種遍歷次序是:深度優先和廣度優先

  • 在廣度優先中,先處理節點的子節點,然后遍歷下一層

  • 在深度優先中,根據根節點訪問順序的不同又分為了

    • 先序遍歷,根節點首先被訪問,然后先序遍歷左子樹,最后先序遍歷右子樹
    • 中序遍歷,先中序遍歷左子樹,然后訪問跟根節點,最后中序遍歷右子樹
    • 后序遍歷,先后序遍歷左子樹,接着后序遍歷右子樹,最后訪問根節點
// 先序遍歷 // 先訪問根節點,然后是左子樹,最后是右子樹 // 所謂訪問就是獲取節點所保存的數據, // 如果將樹葉當作左右節點都為空的根節點,則訪問指的就是訪問每層的根節點。 function preorder(node) { if (node) { console.log(node.data); preorder(node.left); preorder(node.right); } } // 中序遍歷 // 先遍歷左子樹,然后是根節點,最后是右子樹 function inorder(node) { if (node) { inorder(node.left); console.log(node.data); inorder(node.right); } } // 后序遍歷 // 先訪問左子樹,然后是右子樹,最后是根節點 function postorder(node) { if (node) { postorder(node.left); postorder(node.right); console.log(node.data); } } 復制代碼

遍歷AST一般采用的方式是從根節點開始,通過深度優先遍歷AST,將不同類型的節點保存在目標語言的對象中,方便后續的代碼生成。

同樣以Vue的源碼為例,在optimizer中,從根節點開始,遍歷所有節點,並將對應類型的節點標記為static。

export function optimize (root: ?ASTElement, options: CompilerOptions) { if (!root) return isStaticKey = genStaticKeysCached(options.staticKeys || '') isPlatformReservedTag = options.isReservedTag || no // first pass: mark all non-static nodes. markStatic(root) // second pass: mark static roots. markStaticRoots(root, false) } 復制代碼

獲取到AST,且掌握了對它的遍歷方法之后,我們就可以對它進行接下來的操作了。

轉換

解析完成后的下一步是轉換,它只是把 AST 拿過來然后對它做一些修改。它可以在同種語言下作 AST,也可以把 AST 翻譯成全新的語言。

如果解析階段獲取的AST能滿足后續的需求,那么轉換步驟也許並不是必須的。

代碼生成

代碼生成階段主要是根據轉換階段的AST結果來輸出目標代碼,代碼生成器知道如何打印AST獲取的各種節點,然后遞歸調用自身,直到所有節點都被打印在一個很長的字符串中,最后輸出目標代碼。

因此代碼生成階段最主要的工作,仍舊是遍歷AST,並根據每個節點的類型,輸出合適的代碼。

代碼實現

現在我們大致了解了編譯器解析、轉換和代碼生成這三個流程,接下來讓我們來一步一步地實現前文提到的編譯器。

解析

首先我們通過遍歷源碼(一個很長的字符串)

sayHello(userName:getUsername("shymean"), msg:"hello"); 復制代碼

將其拆分到一個token數組里面

[ { type: 'name', value: 'sayHello' }, { type: 'paren', value: '(' }, { type: 'name', value: 'userName' }, { type: 'colon', value: ':' }, { type: 'name', value: 'getUsername' }, { type: 'paren', value: '(' }, { type: 'string', value: 'shymean' }, { type: 'paren', value: ')' }, { type: 'comma', value: ',' }, { type: 'name', value: 'msg' }, { type: 'colon', value: ':' }, { type: 'string', value: 'hello' }, { type: 'paren', value: ')' } 復制代碼

接着遍歷這個token數組,將其解析成對應的ast

{
    type: "Program", body: [ { type: "CallExpression", name: "sayHello", params: [ { type: "NameParam", name: "userName", value: { type: "CallExpression", name: "getUsername", params: [{ type: "StringLiteral", value: "shymean" }] } }, { type: "NameParam", name: "msg", value: { type: "StringLiteral", value: "hello" } } ] } ] } 復制代碼

拆分tokens和將tokens的具體實現可以在源碼中查到。

轉換

看起來我們的簡易編譯器並不需要生成新的AST,因為他們的函數調用、參數都是類似的,唯一的區別在於dart的命名參數在JS中需要通過對象參數來實現。因此轉換這一步被我們簡單地略過了,在the-super-tiny-compiler這個項目中,實現了轉換功能,將原始的ast轉換成更符合JavaScript語義的AST,如函數的callearguments等屬性

代碼生成

在我們的編譯器中,這一步的實現是非常簡單的:對於NameParam類型的節點,我們會在參數列表首尾拼接{}就大功告成了

function codeGenerator(node, index, nodeList) { switch (node.type) { // ...其他類型 case "NameParam": let str = ""; // 第一個參數前拼接'{' if (index === 0) { str += "{"; } str += node.name + ":" + codeGenerator(node.value); // 最后一個參數后拼接 '}' if (index === nodeList.length - 1) { str += "}"; } return str; } } 復制代碼

將上面的流程匯總,然后就可以得到一個非常簡單的編譯器了

function compiler(input) { let tokens = tokenizer(input); let ast = parser(tokens); let output = codeGenerator(ast); return output; } let input = `sayHello(userName:getUsername("shymean"), msg:"hello");`; let code = compiler(input); // sayHello({userName:getUsername("shymean"), msg:"hello"}) 復制代碼

小結

本文先了解了編譯器的基本工作流程,然后介紹了詞法分析和語法分析的基本實現,接着介紹了遍歷AST的方法,最后將各個階段結合起來,實現了一個十分建議的編譯器。

雖然實現的編譯器看起來只是添加了一對{},實際上卻大致展示一個編譯器的工作流程

  • 詞法分析和語法分析,將源代碼轉換成AST
  • 根據AST輸出新的代碼,如果目標代碼是比較底層的中間代碼或機器代碼,工作可能會更加繁瑣

當然僅僅了解這一些東西是萬萬不夠的,不過萬事開頭難,了解到編譯器的雛形和基本概念之后,進一步的學習才會更有動力,繼續學習吧~


作者:橙紅年代
鏈接:https://juejin.im/post/5ceff22af265da1b6b1cbb90


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM