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

本文同步在我的博客shymean.com上,歡迎關注javascript

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

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

儘管社區的工具如bable*-loader已經幫咱們完成了上面的全部工做,咱們不用關心編譯的過程,甚至也不多有人關注輸出的代碼,可是瞭解編譯原理仍是頗有必要的,這篇文章主要用來記錄我在學習編譯原理時整理的一些筆記。html

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

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

寫在前面

在瞭解編譯器的相關概念以前,讓咱們先肯定一下究竟是稱呼"編譯器"仍是"解釋器"。java

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

從靜態編譯與動態解釋這兩種程序運行的方式,能夠引伸出兩個工具git

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

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

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輸出新的代碼,若是目標代碼是比較底層的中間代碼或機器代碼,工做可能會更加繁瑣

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

相關文章
相關標籤/搜索