如何寫一個類似 LESS 的編譯工具?

很好奇less是怎麼寫出來的,如果我也想寫個html和js等等之類的編譯器,我需要學習什麼內容,有哪些資源是我可以參考的呢?


聲明: 這個回答只滿足一般前端工作者,但不適合語言實現領域的『專家』

謝邀: 我之前也造過一個輪子,GitHub - leeluolee/mcss: writing modular css witch mcss ,如果你拋開對star的成見,忽略它貌似已經被"棄坑"的現狀,你其實會發現語言能力上比Less要強一些,而且它確實在公司中數十個產品線中穩定使用了3年以上(不乏考拉、易信等較大的產品線),所以算是一個成熟的可參考對象,絕非玩具。

LESS或者Jade這類css和html的預處理語言,都可以被歸類為為DSL, 即

DSL( Domain Specific Language ) , 字面翻譯為領域受限的編程語言. 它與我們日常使用的一般編程語言不同,是被設計用來解決 特定問題域語言工具 ,DSL一般分為內部DSL與外部DSL。這裡我們的關注點是外部DSL

(關於內部DSL和外部DSL就不細講了,不過我在公司內有一次比較全面的沙龍分享,有興趣的可以問我要PPT)

我先來介紹下,mcss這種compile to css的DSL的實現流水線(所有的compile to js, compile to xx的流水線都大同小異).

1. 詞法解析Tokenizer or Lexer

作用相當於自然語言的分詞,比如 total + number ,total和number此時都會輸出類似 Identifier 這樣詞法元素,並且一些無效的元素比如空格回車縮進(這個取決於語言設計,有些語言是有意義的)會被剔除。它輸出的是扁平化結構, 一般是一個token隊列結構, 或是一個token Stream 供Parser 使用

2. 語法解析Parser: 即將詞語根據Grammer組裝成語義結構,

通常是輸出是一個樹形結構,實現上會涉及大量的遞歸操作。基本常見解決思路有LL和LR兩種,LR由於與我們思維的模式有些出入,很難人工書寫,所以一般都是靠代碼生成來實現。這裡我們就更一般的LL做下簡單描述。 常見有LL(1), LL(2) , LL(n)等分別,即你向前看多少個『詞』(n即代表要查看不定數量),可以確定他的語法的節點類型,以SASS為例,如function 定義

@function xx-mixin

當我們碰到 @function 時候已經可以判斷這會是個 type 為function的 At-rule 的定義, 所以LL(1)即向前看一位的Parser就能解決這個場景

mcss有點特殊,是個LL(n)的解釋器,比如在設計中,函數在mcss是 First-class的,可以被返回或傳入函數,並保持作用域信息,所以它是一種特殊的值,定義我設計與一般賦值一樣。

$size = ($width, $height) {
// ...
}

這裡當你不讀取到`{ ` 是無法判斷 `=` 後面是函數定義 還是 普通css中的 compound values . 眾所周知參數列表可能無限長,所以必須是LL(n)的Parser才能夠解答。

雖然帶來一定的性能影響和實現成本(比如回溯、中斷),但是帶來語言語法上的一些靈活性。

3. 一次或多次的Transformer:

在最終輸出前,我們需要對現有的AST做適當調整以滿足輸出要求。

在mcss有 Interpreter, 因為內部需要將所有css之外的語法元素,比如Function, Expression之類的全部解釋一遍,輸出純粹的CSS AST. 一般的compile to xx 都不需要interpreter這一步,因為像JS這種目標語言本身就具有完整的語言能力,但CSS是個特例,我們創建css預處理語言就是要增加它的語言能力。在經過這次Interpreter之後,就變成了純粹的代表CSS語法元素的AST結構,這步之後,我們會在Interpreter上升階段將其傳給一個css_walker的流程提供給插件使用,這樣用戶只需要關注css相關的語法元素即可實現插件擴展。 和現在postcss暴露給開發者的pugin API類似。使用這種walker可以幫你實現很多有用的功能,比如80行實現一個csscss冗餘檢測工具(80line implement more powerful csscss (https://github.com/zmoazeni/csscss) version · Issue #3 · leeluolee/mcss · GitHub) . 圖片link替換base64等等功能。

4. Translator 翻譯器:

輸出最終的CSS文本,這裡輸入的AST, 除了原來的語言中除了語法節點原有的信息,其實還包含有position這種位置信息, 在輸出時,你同時處理輸入和輸出的兩個位置信息, 就可以實現了source-map的功能,不想處理map編碼可以直接使用 mozilla/source-map ,你只需要傳入輸入的行列和輸出行列即可。

流程介紹完畢,那麼題主,我假設題主是一個沒有任何語言實現基礎的前端開發者,那麼我覺得優先順序應該是:

1. 設計一門外部DSL,你首先需要是目標語言的"大師" ,這樣你設計的DSL才可能是合理的,是符合問題域的。

就我個人而言,我目前至少通讀過CSS Syntax和html5 Syntax以及 ES5的完整規範,這用來支持我完成那些開源或非開源的DSL工具。 還有一點就是,對於JS, 你應該把SpiderMonkey的 Parser API - Mozilla 通讀幾次是基本的,特別是AST節點的Interface定義,這是市面上幾乎所有JS parser和 code generator的基石 。

2. 合格『夠用的』編譯原理前端知識:

誠如 @郝立鑫 所言,一般我們前端領域所碰到的問題,只需要語言實現領域知識的皮毛,並且大部分集中在前端部分(此前端非彼前端)。但即使如此,對於非科班的同學也有一定的學習代價。在這裡我不推薦任何龍書之類。我只推薦《編程語言實現模式》,我覺得這本書雖然評價一般,但是是我看到的唯一一本脫離學院派味道的實戰書籍。不過在你使用JS實現你的DSL時,由於JS語言非常靈活,拋開性能因素,你可以實現的比JAVA這類語言更加的優雅,其中精通正則表達式,可以讓你在實現中少走很多彎路。然後推薦 Flex 和 Bison這本來了解別人是如何設計語言的, javascript對應的版本 是jison, 比如handlebar等模板引擎都是基於jison實現。

3. 理論結合實際的能力,為什麼現在科班出身的前端不再少數,但鮮有人在前端DSL領域有過深耕?我覺得一方面是我們教育體制的填鴨教育問題,另一方面公司普遍業務導向,缺乏對人的培養,所有分享等激勵措施都流於表面等實際現狀脫不了干係。

4. 實際使用的客戶群體,論證設計和實現的合理性。這個,相比之下已是老幹部的人(比如我)相比入職的新人可能更加有優勢些。

Parser這個基本領域是個已經被解決的問題,解決方案已經被固化為各種理論支持的Parser Generator方案。 但是在應用層面,如何在特定領域如日新月異的前端開發領域,如何結合特定宿主語言,仍然大有可為。

其實,每一個寫DSL足夠多的人,都會非常想去實現一個Parser Generator.


之前看到 @尤雨溪 大大提到這個:

GitHub - thejameskyle/the-super-tiny-compiler: Possibly the smallest compiler ever

這是一個非常非常非常小的 JavaScript 寫成的編譯器(去掉注釋代碼只有200多行),雖然並沒有什麼卵用但是可以向我們展示編譯器的很多部分。

這幾天把它翻譯成了中文:

GitHub - starkwang/the-super-tiny-compiler

用 JavaScript 寫一個超小型編譯器 - 一隻碼農的技術日記 - 知乎專欄

如果懶的話看下面的代碼也行,不過知乎APP對於代碼的閱讀體驗很差,建議選擇『瀏覽器打開』

/**
* 今天讓我們來寫一個編譯器,一個超級無敵小的編譯器!它小到如果把所有注釋刪去的話,大概只剩
* 200行左右的代碼。
*
* 我們將會用它將 lisp 風格的函數調用轉換為 C 風格。
*
* 如果你對這兩種風格不是很熟悉,下面是一個簡單的介紹。
*
* 假設我們有兩個函數,`add` 和 `subtract`,那麼它們的寫法將會是下面這樣:
*
* LISP C
*
* 2 + 2 (add 2 2) add(2, 2)
* 4 - 2 (subtract 4 2) subtract(4, 2)
* 2 + (4 - 2) (add 2 (subtract 4 2)) add(2, subtract(4, 2))
*
* 很簡單對吧?
*
* 這個轉換就是我們將要做的事情。雖然這並不包含 LISP 或者 C 的全部語法,但它足以向我們
* 展示現代編譯器很多要點。
*
*/

/**
* 大多數編譯器可以分成三個階段:解析(Parsing),轉換(Transformation)以及代碼
* 生成(Code Generation)
*
* 1. *解析*是將最初原始的代碼轉換為一種更加抽象的表示(譯者註:即AST)。*
*
* 2. *轉換*將對這個抽象的表示做一些處理,讓它能做到編譯器期望
* 它做到的事情。
*
* 3. *代碼生成*接收處理之後的代碼表示,然後把它轉換成新的代碼。
*/

/**
* 解析(Parsing)
* -------
*
* 解析一般來說會分成兩個階段:詞法分析(Lexical Analysis)和語法分析(Syntactic Analysis)。
*
* 1. *詞法分析*接收原始代碼,然後把它分割成一些被稱為 Token 的東西,這個過程是在詞法分析
* 器(Tokenizer或者Lexer)中完成的。
*
* Token 是一個數組,由一些代碼語句的碎片組成。它們可以是數字、標籤、標點符號、運算符,
* 或者其它任何東西。
*
* 2. *語法分析* 接收之前生成的 Token,把它們轉換成一種抽象的表示,這種抽象的表示描述了代
* 碼語句中的每一個片段以及它們之間的關係。這被稱為中間表示(intermediate representation)
* 或抽象語法樹(Abstract Syntax Tree, 縮寫為AST)
*
* 抽象語法樹是一個嵌套程度很深的對象,用一種更容易處理的方式代表了代碼本身,也能給我們
* 更多信息。
*
* 比如說對於下面這一行代碼語句:
*
* (add 2 (subtract 4 2))
*
* 它產生的 Token 看起來或許是這樣的:
*
* [
* { type: "paren", value: "(" },
* { type: "name", value: "add" },
* { type: "number", value: "2" },
* { type: "paren", value: "(" },
* { type: "name", value: "subtract" },
* { type: "number", value: "4" },
* { type: "number", value: "2" },
* { type: "paren", value: ")" },
* { type: "paren", value: ")" }
* ]
*
* 它的抽象語法樹(AST)看起來或許是這樣的:
*
* {
* type: "Program",
* body: [{
* type: "CallExpression",
* name: "add",
* params: [{
* type: "NumberLiteral",
* value: "2"
* }, {
* type: "CallExpression",
* name: "subtract",
* params: [{
* type: "NumberLiteral",
* value: "4"
* }, {
* type: "NumberLiteral",
* value: "2"
* }]
* }]
* }]
* }
*/

/**
* 轉換(Transformation)
* --------------
*
* 編譯器的下一步就是轉換。它只是把 AST 拿過來然後對它做一些修改。它可以在同種語言下操
* 作 AST,也可以把 AST 翻譯成全新的語言。
*
* 下面我們來看看該如何轉換 AST。
*
* 你或許注意到了我們的 AST 中有很多相似的元素,這些元素都有 type 屬性,它們被稱為 AST
* 結點。這些結點含有若干屬性,可以用於描述 AST 的部分信息。
*
* 比如下面是一個「NumberLiteral」結點:
*
* {
* type: "NumberLiteral",
* value: "2"
* }
*
* 又比如下面是一個「CallExpression」結點:
*
* {
* type: "CallExpression",
* name: "subtract",
* params: [...nested nodes go here...]
* }
*
* 當轉換 AST 的時候我們可以添加、移動、替代這些結點,也可以根據現有的 AST 生成一個全新
* 的 AST
*
* 既然我們編譯器的目標是把輸入的代碼轉換為一種新的語言,所以我們將會著重於產生一個針對
* 新語言的全新的 AST。
*
*
* 遍歷(Traversal)
* ---------
*
* 為了能處理所有的結點,我們需要遍歷它們,使用的是深度優先遍歷。
*
* {
* type: "Program",
* body: [{
* type: "CallExpression",
* name: "add",
* params: [{
* type: "NumberLiteral",
* value: "2"
* }, {
* type: "CallExpression",
* name: "subtract",
* params: [{
* type: "NumberLiteral",
* value: "4"
* }, {
* type: "NumberLiteral",
* value: "2"
* }]
* }]
* }]
* }
*
* So for the above AST we would go:
* 對於上面的 AST 的遍歷流程是這樣的:
*
* 1. Program - 從 AST 的頂部結點開始
* 2. CallExpression (add) - Program 的第一個子元素
* 3. NumberLiteral (2) - CallExpression (add) 的第一個子元素
* 4. CallExpression (subtract) - CallExpression (add) 的第二個子元素
* 5. NumberLiteral (4) - CallExpression (subtract) 的第一個子元素
* 6. NumberLiteral (4) - CallExpression (subtract) 的第二個子元素
*
* 如果我們直接在 AST 內部操作,而不是產生一個新的 AST,那麼就要在這裡介紹所有種類的抽象,
* 但是目前訪問(visiting)所有結點的方法已經足夠了。
*
* 使用「訪問(visiting)」這個詞的是因為這是一種模式,代表在對象結構內對元素進行操作。
*
* 訪問者(Visitors)
* --------
*
* 我們最基礎的想法是創建一個「訪問者(visitor)」對象,這個對象中包含一些方法,可以接收不
* 同的結點。
*
* var visitor = {
* NumberLiteral() {},
* CallExpression() {}
* };
*
* 當我們遍歷 AST 的時候,如果遇到了匹配 type 的結點,我們可以調用 visitor 中的方法。
*
* 一般情況下為了讓這些方法可用性更好,我們會把父結點也作為參數傳入。
*/

/**
* 代碼生成(Code Generation)
* ---------------
*
* 編譯器的最後一個階段是代碼生成,這個階段做的事情有時候會和轉換(transformation)重疊,
* 但是代碼生成最主要的部分還是根據 AST 來輸出代碼。
*
* 代碼生成有幾種不同的工作方式,有些編譯器將會重用之前生成的 token,有些會創建獨立的代碼
* 表示,以便於線性地輸出代碼。但是接下來我們還是著重於使用之前生成好的 AST。
*
* 我們的代碼生成器需要知道如何「列印」AST 中所有類型的結點,然後它會遞歸地調用自身,直到所
* 有代碼都被列印到一個很長的字元串中。
*
*/

/**
* 好了!這就是編譯器中所有的部分了。
*
* 當然不是說所有的編譯器都像我說的這樣。不同的編譯器有不同的目的,所以也可能需要不同的步驟。
*
* 但你現在應該對編譯器到底是個什麼東西有個大概的認識了。
*
* 既然我全都解釋一遍了,你應該能寫一個屬於自己的編譯器了吧?
*
* 哈哈開個玩笑,接下來才是重點 :P
*
* 所以我們開始吧...
*/

/**
* =======================================================================
* (/^▽^)/
* 詞法分析器(Tokenizer)!
* =======================================================================
*/

/**
* 我們從第一個階段開始,即詞法分析,使用的是詞法分析器(Tokenizer)。
*
* 我們只是接收代碼組成的字元串,然後把它們分割成 token 組成的數組。
*
* (add 2 (subtract 4 2)) =&> [{ type: "paren", value: "(" }, ...]
*/

// 我們從接收一個字元串開始,首先設置兩個變數。
function tokenizer(input) {

// `current`變數類似指針,用於記錄我們在代碼字元串中的位置。
var current = 0;

// `tokens`數組是我們放置 token 的地方
var tokens = [];

// 首先我們創建一個 `while` 循環, `current` 變數會在循環中自增。
//
// 我們這麼做的原因是,由於 token 數組的長度是任意的,所以可能要在單個循環中多次
// 增加 `current`
while (current &< input.length) { // 我們在這裡儲存了 `input` 中的當前字元 var char = input[current]; // 要做的第一件事情就是檢查是不是右圓括弧。這在之後將會用在 `CallExpressions` 中, // 但是現在我們關心的只是字元本身。 // // 檢查一下是不是一個左圓括弧。 if (char === "(") { // 如果是,那麼我們 push 一個 type 為 `paren`,value 為左圓括弧的對象。 tokens.push({ type: "paren", value: "(" }); // 自增 `current` current++; // 結束本次循環,進入下一次循環 continue; } // 然後我們檢查是不是一個右圓括弧。這裡做的時候和之前一樣:檢查右圓括弧、加入新的 token、 // 自增 `current`,然後進入下一次循環。 if (char === ")") { tokens.push({ type: "paren", value: ")" }); current++; continue; } // 繼續,我們現在檢查是不是空格。有趣的是,我們想要空格的本意是分隔字元,但這現在 // 對於我們儲存 token 來說不那麼重要。我們暫且擱置它。 // // 所以我們只是簡單地檢查是不是空格,如果是,那麼我們直接進入下一個循環。 var WHITESPACE = /s/; if (WHITESPACE.test(char)) { current++; continue; } // 下一個 token 的類型是數字。它和之前的 token 不同,因為數字可以由多個數字字元組成, // 但是我們只能把它們識別為一個 token。 // // (add 123 456) // ^^^ ^^^ // Only two separate tokens // 這裡只有兩個 token // // 當我們遇到一個數字字元時,將會從這裡開始。 var NUMBERS = /[0-9]/; if (NUMBERS.test(char)) { // 創建一個 `value` 字元串,用於 push 字元。 var value = ""; // 然後我們循環遍歷接下來的字元,直到我們遇到的字元不再是數字字元為止,把遇到的每 // 一個數字字元 push 進 `value` 中,然後自增 `current`。 while (NUMBERS.test(char)) { value += char; char = input[++current]; } // 然後我們把類型為 `number` 的 token 放入 `tokens` 數組中。 tokens.push({ type: "number", value: value }); // 進入下一次循環。 continue; } // 最後一種類型的 token 是 `name`。它由一系列的字母組成,這在我們的 lisp 語法中 // 代表了函數。 // // (add 2 4) // ^^^ // Name token // var LETTERS = /[a-z]/i; if (LETTERS.test(char)) { var value = ""; // 同樣,我們用一個循環遍歷所有的字母,把它們存入 value 中。 while (LETTERS.test(char)) { value += char; char = input[++current]; } // 然後添加一個類型為 `name` 的 token,然後進入下一次循環。 tokens.push({ type: "name", value: value }); continue; } // 最後如果我們沒有匹配上任何類型的 token,那麼我們拋出一個錯誤。 throw new TypeError("I dont know what this character is: " + char); } // 詞法分析器的最後我們返回 tokens 數組。 return tokens; } /** * ======================================================================= * ヽ/?o ? o? * 語法分析器(Parser)!!! * ======================================================================= */ /** * 語法分析器接受 token 數組,然後把它轉化為 AST * * [{ type: "paren", value: "(" }, ...] =&> { type: "Program", body: [...] }
*/

// 現在我們定義 parser 函數,接受 `tokens` 數組
function parser(tokens) {

// 我們再次聲明一個 `current` 變數作為指針。
var current = 0;

// 但是這次我們使用遞歸而不是 `while` 循環,所以我們定義一個 `walk` 函數。
function walk() {

// walk函數里,我們從當前token開始
var token = tokens[current];

// 對於不同類型的結點,對應的處理方法也不同,我們從 `number` 類型的 token 開始。
// 檢查是不是 `number` 類型
if (token.type === "number") {

// 如果是,`current` 自增。
current++;

// 然後我們會返回一個新的 AST 結點 `NumberLiteral`,並且把它的值設為 token 的值。
return {
type: "NumberLiteral",
value: token.value
};
}

// 接下來我們檢查是不是 CallExpressions 類型,我們從左圓括弧開始。
if (
token.type === "paren"
token.value === "("
) {

// 我們會自增 `current` 來跳過這個括弧,因為括弧在 AST 中是不重要的。
token = tokens[++current];

// 我們創建一個類型為 `CallExpression` 的根節點,然後把它的 name 屬性設置為當前
// token 的值,因為緊跟在左圓括弧後面的 token 一定是調用的函數的名字。
var node = {
type: "CallExpression",
name: token.value,
params: []
};

// 我們再次自增 `current` 變數,跳過當前的 token
token = tokens[++current];

// 現在我們循環遍歷接下來的每一個 token,直到我們遇到右圓括弧,這些 token 將會
// 是 `CallExpression` 的 `params`(參數)
//
// 這也是遞歸開始的地方,我們採用遞歸的方式來解決問題,而不是去嘗試解析一個可能有無限
// 層嵌套的結點。
//
// 為了更好地解釋,我們來看看我們的 Lisp 代碼。你會注意到 `add` 函數的參數有兩個,
// 一個是數字,另一個是一個嵌套的 `CallExpression`,這個 `CallExpression` 中
// 包含了它自己的參數(兩個數字)
//
// (add 2 (subtract 4 2))
//
// 你也會注意到我們的 token 數組中有多個右圓括弧。
//
// [
// { type: "paren", value: "(" },
// { type: "name", value: "add" },
// { type: "number", value: "2" },
// { type: "paren", value: "(" },
// { type: "name", value: "subtract" },
// { type: "number", value: "4" },
// { type: "number", value: "2" },
// { type: "paren", value: ")" }, &<&<&< 右圓括弧 // { type: "paren", value: ")" } &<&<&< 右圓括弧 // ] // // 遇到嵌套的 `CallExpressions` 時,我們將會依賴嵌套的 `walk` 函數來 // 增加 `current` 變數 // // 所以我們創建一個 `while` 循環,直到遇到類型為 `"paren"`,值為右圓括弧的 token。 while ( (token.type !== "paren") || (token.type === "paren" token.value !== ")") ) { // 我們調用 `walk` 函數,它將會返回一個結點,然後我們把這個節點 // 放入 `node.params` 中。 node.params.push(walk()); token = tokens[current]; } // 我們最後一次增加 `current`,跳過右圓括弧。 current++; // 返回結點。 return node; } // 同樣,如果我們遇到了一個類型未知的結點,就拋出一個錯誤。 throw new TypeError(token.type); } // 現在,我們創建 AST,根結點是一個類型為 `Program` 的結點。 var ast = { type: "Program", body: [] }; // 現在我們開始 `walk` 函數,把結點放入 `ast.body` 中。 // // 之所以在一個循環中處理,是因為我們的程序可能在 `CallExpressions` 後面包含連續的兩個 // 參數,而不是嵌套的。 // // (add 2 2) // (subtract 4 2) // while (current &< tokens.length) { ast.body.push(walk()); } // 最後我們的語法分析器返回 AST return ast; } /** * ======================================================================= * ⌒(?&>???& tokenizer =&> tokens
* 2. tokens =&> parser =&> ast
* 3. ast =&> transformer =&> newAst
* 4. newAst =&> generator =&> output
*/

function compiler(input) {
var tokens = tokenizer(input);
var ast = parser(tokens);
var newAst = transformer(ast);
var output = codeGenerator(newAst);

// 然後返回輸出!
return output;
}

/**
* =======================================================================
* (??????)
* !!!!!!!!!!!!!!!!!!!!!!!!!!!!!你做到了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
* =======================================================================
*/

// 現在導出所有介面...
module.exports = {
tokenizer: tokenizer,
parser: parser,
transformer: transformer,
codeGenerator: codeGenerator,
compiler: compiler
};


首先閱讀相關的標準和規範是必須的:

  • HTML 4.01

    http://www.w3.org/TR/html401/

  • HTML 5

    http://www.w3.org/TR/html5/
  • ECMAScript 5.1

    http://es5.github.com/

然後嘛編譯原理相關的只是肯定也是需要的,這個找本書看看吧。

還有就是可以結合開源類庫的源碼研究一下。


船到橋頭自然直,先好好使用手頭的技術,然後去發現存在的問題,再想著去解決問題,這個時候你自然就會知道該用什麼樣的方法去解決。而不要先想著我要寫個東西,再去尋找我能寫出來的東西對應的問題和寫的方法。


我記得lesscss是開源的直接去看它的源代碼是一個方法。


嚴格來說less等叫翻譯轉換器,它們並未涉及完整編譯器的後端部分,只是做了前端一塊兒。其技術核心是解析器,而解析核心則是自動機。這個入門可以看json格式,能自己寫出json解析就算成功了,其它的語言只是更加複雜而已。自動機說簡單了就是一次遍歷原始代碼字元串,根據當前的字元哨兵peek和當前的狀態state,推導出下一個狀態是啥。這也是大部分人初寫的手工LL1解析器,LR之類等需要再說。一般語言規範那裡會幫你定義好無歧義無左遞歸的格式。解析完了之後,做翻譯轉換就是普通邏輯的事情了。比如less的變數,解析出變數聲明的k和v,將源碼中用到的地方替換下。npm/github可以看我的homunclus源碼,jsx html css js解析都包括在裡面。


在理論以及代碼實踐方面前面已有比較好的答案,但可能對於不具備編譯原理相關知識的同學來說,還是不知道如何下手,thejameskyle/the-super-tiny-compiler 是一個很好的例子,前面的答案也已經給出,如果已經很好的理解了其源碼,應該對於如何編寫less這樣的編譯器有一個比較清晰的思路了,但是有一個沒有解決的問題就是,例子中的詞法以及語法分析代碼是比較簡單的,針對於lisp這樣的S表達式的語法還是ok,但是擴展到複雜語法的語言,又會擴大了門檻,畢竟如果parser這一步不解決,那麼後面的也無從談起。

在里簡單的介紹一種自己用得比較多的parser方法,不需要使用任何工具就能夠實現複雜編程語法的語法解析,其實屬於parser generate的一種,示例代碼可以參考https://github.com/xiaofuzi/scheme-to-js/blob/master/src/lisp.js,完全手寫實現的簡單的lisp解釋器(其實只寫了四則運算的解釋執行就沒在繼續。。。),重點是parser部分,這裡採用的是一種叫組合子的方式,詞法和語法分析同時進行,一步就得到了抽象語法樹,同時非常容易的擴展到不同的語法。

以其中的一段代碼為例:

/**
* atom parser
*/

//匹配左括弧
function leftParen () {
return ch("(");
}

//匹配右括弧
function rightParen () {
return ch(")");
}

//匹配0到9之間的數字
function int () {
return action(range("0", "9"), function(ast){
return {
type: "NumberLiteral",
value: ast
}
});
}

//匹配由大小寫字母任意組合的標示符
function identifer () {
var result = repeat1(
choice(
range("a", "z"),
range("A", "Z")
)
)
return action(result, function(ast){
return ast.join("");
})
}

/**
* simple expression (add 2 4)
*/
//簡單的S表達式(這裡已經具有語法結構了)
function atomCallExpression () {
return function (state) {
var result = wsequence(
leftParen(),
choice(
identifer(),
operator()
),
int(),
int(),
rightParen()
)(state);
return {
remaining: result.remaining,
matched: result.matched,
ast: {
type: "CallExpression",
name: result.ast[1],
params: [
result.ast[2],
result.ast[3]
]
}
};
}
}

每個parser函數對應於一個簡單的parser,通過parser之間的組合就可以得到更為複雜的parser,以上述代碼為例,

首先實現了leftParen、rightParen、int、identifer基本的parser,他們分別可以解析"("、")"、整數、標示符,對於它們單個來說是做不了什麼的,但是將其組合起來,就可以匹配比較複雜的語法,如atomCallExpression由上述四個簡單的parser組合起來就能夠解析(add 2 4)這樣的語法,進一步的組合又可以匹配更為複雜的語法,如callExpression可以匹配 (add 3 (sub 4 1))這樣的語法,並支持深層次的遞歸,這是正則表達式做不到的(js的正則不支持遞歸匹配)。

編程語言與自然語言相比有一個特點是,每一個語法都有相應的規則並且不會出現歧義,所以只有按照語言的語法規則去組合得到相應的parser就能夠實現對其的parser,得到抽象語法樹,得到抽象語法樹後在進行語法樹的轉換、代碼生成就可以實現像less這樣的編譯器了。


當時在做項目架構的時候需要一個按模塊樣式隔離的CSS預處理器,可惜沒找到。一怒之下自己寫了一個。VUE也實現了樣式隔離然而是基於屬性選擇器,增加了選擇器的複雜度。

用JISON做的parser。詞法,語法規則參考W3C CSS的規則。

https://github.com/simonhao/made-style


這個類庫可以實現在JS中動態嵌入LESS代碼:

GitHub - futurist/cssobj-less: LESS / Bootstrap in JS, dynamically change variables of LESS / Bootstrap, and more!

如果有興趣還可以看看這個類庫,把CSS嵌入到JS中:

https://github.com/cssobj/cssobj


我想說less不是編譯器,先把已有的CSS預處理語言用好再說吧


推薦閱讀:

關於 CSS 的好書有哪些?
CSS 里的 height 屬性與 line-height 屬性有什麼區別?
如何評價真阿當在前端領域的技術水平?
如何解決外邊距疊加的問題?

TAG:CSS | 編譯原理 | Less |