Javascript與抽象語法樹

笨笨小撒發表於2019-03-01

各位JSer大家好,今天我想和大家聊聊我最感興趣的話題之一:什麼是抽象語法樹,以及通過它我們能做些什麼。我們會通過編寫幾個簡單的demo來了解抽象語法樹。

你可以在這裡下載到可執行的例項程式碼,以便了解一下我們今天將覆蓋的幾個demo。

名詞解釋

首先讓我們來了解一下以下名詞:抽象語法樹、詞法分析、語法分析。

抽象語法樹

抽象語法樹(Abstract Syntax Tree,AST。後文將統一使用AST來稱呼)指的是原始碼的抽象語法結構的樹狀表現形式,樹上的每個節點都表示原始碼中的一種結構。

例如對於if-else結構的程式碼可能會以一個帶有3個子節點(判斷條件,判斷為真時的邏輯,判斷為假時的邏輯)所組成的節點,而while結構的程式碼則可能包含兩個子節點(判斷條件,迴圈體的邏輯)。

程式碼從原始碼轉換為抽象語法樹將經歷兩個重要的過程:詞法分析與語法分析。

詞法分析

詞法分析,或簡稱為分詞(tokenize)指的是將原始碼的字元序列轉換為單詞(token)序列的過程。完成這一工作的工具被稱為詞法分析器(lexer,或tokenizer)。

例如對於a = b + 1這段程式碼,在詞法分析後將得到[a, =, b, +, 1]這五個單詞(或在最後再包含一個EOF標識)。每個單詞可能會包含如下資訊:

  • 單詞的原文,例如'a','=','1'
  • 單詞在原始碼中的位置,例如b的位置是從56
  • 單詞的型別,例如a是一個識別符號,它表示的是一個變數名,而=+是操作符,1則是一個字面量。

語法分析

通過詞法分析,我們獲得了鏈狀的單詞序列,之後語法分析器(Parser)會根據語法規則將單詞序列轉化為樹狀的AST。我們通常會使用巴科斯正規化(Backus-Naur Form,BNF)來描述我們的語法。

在語法分析的過程中,我們還可以通過一些優化規則,對語法樹的結構做出一些改動,使得編譯器(compiler)/直譯器(interpreter)在執行程式碼時更加高效。

工具

今天我們將依賴於acorn.js提供的解析功能,它能將JS程式碼解析為AST。

也許有些小夥伴還不知道acorn.js是什麼,但很可能你已經接觸過並正在使用它,因為很多工具都依賴於它:例如webpackuglify-eseslint等等。

我們今天並不會深入探索它的工作原理(我們將在以後的文章中對語法分析的原理進行探索),因此只需要通過文件瞭解一下它所提供的API就可以了。

原始碼

接下來,我們將圍繞以下程式碼通過acorn.js生成的語法樹編寫我們的程式碼:

var sum = 0;
var count = 1;
var result;

while (count <= 10) {
  sum = sum + count;
  count = count + 1;
}

if (count < 10) {
  result = false;
} else {
  result = true;
}
複製程式碼

展示單詞序列

首先我們從一個簡單的任務開始。

acorn.js提供了詞法分析器(tokenizer),通過它,我們可以逐一獲取單詞直至eof

const getTokenList = function getTokenList(code) {
  const tokens = acorn.tokenizer(code);
  const arr = [];
  let token = tokens.getToken();
  while(token.type.label !== 'eof') {
    arr.push(token);
    token = tokens.getToken();
  }
  return arr;
};
複製程式碼

由此,我們可以進行單詞序列的展示:

單詞序列展示

每個單詞都會包含以下資訊:startend標識了單詞在原始碼中的位置,label表示單詞型別,value則是單詞的值。

例如var sum = 0;中的0,位置是1011,型別為num,值為0

我們可以注意到,在單詞序列中,換行和空格都被去除了,但是;是保留的。

展示語法樹

同樣acorn.js提供了語法分析(parse):

const tree = acorn.parse(code);
複製程式碼

acorn.js生成的AST

於是我們可以對acorn.js生成的AST進行視覺化展示:

AST視覺化

我們可以看到,每個節點也包含了節點型別、起始位置、子節點等資訊。不同型別的節點將會擁有不同數量、名稱的子節點。而類似IdentifierLiteral節點將不會包含子節點,但將包含指明其識別符號的name或其值的raw資訊。

程式碼高亮

在單詞序列或是AST的基礎上,我們可以對語法進行程式碼高亮展示。

demo中展示瞭如何使用詞法分析的結果來高亮展示程式碼。

我們需要做的是,藉助單詞序列,替換原始碼,例如將:

var sum = 0;
複製程式碼

替換為:

<span class="token-var">var</span> sum <span class="token-assign">=</span> 0;
複製程式碼

單詞序列提供了單詞的起始位置,因此我們可以很方便的完成這一替換。這裡的小技巧在於,如果你順序進行替換,那麼你需要累計記錄每次替換後產生的便宜,或者你可以從尾部開始替換,或是像我一樣,從單詞序列出發進行組裝,當發現相鄰的單詞並沒有頭尾相接時,我將從原文中取出它們之間的內容(空格和換行)並塞回它們中間。

程式碼高亮

如果我們使用語法分析得到的結果,將可以提供更多有趣的功能,例如允許程式碼塊(Block,例如ifwhile的程式碼塊)的摺疊,或是變數、函式、類的宣告的跳轉等等。

程式碼壓縮

接下來的任務是壓縮程式碼。

基於單詞序列

首先我們將基於單詞序列進行壓縮,由於我們單詞序列中已經不包含空格和換行,我們只需要將單詞序列中每個單詞的value連線起來就行了。不過我們要注意一些特例,例如在var sum之間的空格我們仍然需要重新補上才行:

var a=0;var b=1;var c;while(b<=10){a=a+b;b=b+1;}if(b<10){c=false;}else{c=true;}
複製程式碼

這裡我們還進行了一項小工作:對於變數名,我們將建立一個對應表按照它們首次出現的順序將它們對應為a, b, c..., a0, b0, ...。不過這裡我們並不會考慮巢狀作用域的情況,例如:

var sum = 0;

...

var count = 1;

var func = () => {
	var count = 0;
	...
}
複製程式碼

可能將被轉化為:

var a = 0;

...

var b2 = 1;

var b3 = () => {
	var b2 = 0;
	...
}
複製程式碼

然而事實上兩個count並不在同一個作用域下,因此我們應當使用一張全新的對應表:

var b3 = () => {
	var a = 0;
	...
}
複製程式碼

僅僅在單詞序列的基礎上我們無法提供不同的作用域,在提供了AST資訊的基礎上我們可以對此進行處理(不過demo中我也跳過了這一點)。

基於AST

以上的壓縮程式碼還有不少可以進一步提高的空間。例如;並非總是必須的,例如對於一個塊中的最後一句程式碼:

while(b<=10){a=a+b;b=b+1;}
複製程式碼

可以表示為:

while(b<=10){a=a+b;b=b+1}
複製程式碼

同級下的變數宣告也可以合併:

var a=0,b=1,c;
複製程式碼

這裡我們就需要利用到AST所提供的資訊。

./walker/index.js中我們提供了defaultPs,這是遍歷語法樹的一個例子。defaultPs什麼都沒做,不過我們可以通過修改它來完成各種功能:

const defaultPs = {
  Program(node, env) {
    let item;
    for (item of node.body) {
      defaultPs.process(item, env);
    }
  },
  BlockStatement(node, env) {
    let item;
    for (item of node.body) {
      defaultPs.process(item, env);
    }
  },

  ...

  process(node, env) {
    const p = defaultPs[node.type];
    if (!p) {
      console.error('未處理的型別', node.type, node);
      return;
    }
    return p(node, env);
  },
};
複製程式碼

defaultPs會遍歷語法樹,根據每個節點的型別做出不同的反應。node是當前處理的節點,env用來放置一些全域性的資料,demo中的例項程式碼非常簡單,因此我們只覆蓋了一部分節點型別的處理,例如BlockStatementVariableDeclarationIdentifierWhileStatement等等。

這裡我們遍歷節點並拼接字串:

const joinCode = function joinCode(list) {
  return list.filter(x => x != null).join(';');
};


const ps = {

  ...


  BlockStatement(node, env) {
    const result = [];
    let item;
    result.push(preProcessVars(node, env));
    for (item of node.body) {
      result.push(ps.process(item, env));
    }
    return joinCode(result);
  },
  
  ...

};
複製程式碼

這裡我們再列舉幾個典型的節點的處理:

  Identifier(node, env) {
    return env.nextName.getName(node.name);
  },
  Literal(node, env) {
    // 壓縮truefalse
    if (node.raw === 'true') return '!0';
    if (node.raw === 'false') return '!1';
    return node.raw;
  },
  WhileStatement(node, env) {
    const test = ps.process(node.test, env);
    const body = ps.process(node.body, env);
    return `while(${test}){${body}}`;
  },
  IfStatement(node, env) {
    const test = ps.process(node.test, env);
    const consequent = ps.process(node.consequent, env);
    let str = `if(${test}){${consequent}}`;
    const alternate = ps.process(node.alternate, env)
    if (node.alternate) str += `else{${alternate}}`;
    return str;
  },
  BinaryExpression(node, env) {
    return ps.process(node.left, env) + node.operator + ps.process(node.right, env);
  },
複製程式碼

例如IfStatement是將相應節點的程式碼用ifelse等等連線起來。而它的這些子節點則會被遞迴的處理。

與此前相比,由於我們獲得了樹狀的結構,因此我們可以優先遍歷一顆樹下的所有變數宣告節點,並將它們一起提到頭部,同時我們可以知曉一句程式碼是否是一個塊中的最後一句,從而省略其後的;

demo中生成了如下程式碼:

var a=0,b=1,c;while(b<=10){a=a+b;b=b+1};if(b<10){c=!1}else{c=!0}
複製程式碼

這裡我們又加入了一個小技巧:用!0代替true同時用!1代替false。結果看起來還不錯,但還有可以提高的地方,例如whileif之間的;可以省略,如果你打算做這樣的嘗試,注意區分:

var a = {};if (...) ...
複製程式碼

以及:

while (...) { ... }if (...) ...
複製程式碼

之間的區別。你需要搞清楚前一個節點的}來自與一個還是其它什麼例如物件字面量,來決定是否兩個節點之間不需要;相連。

在demo中我們將變數宣告統一提到了程式碼塊的頭部。作為JSer我想你一定聽說過變數提升,或許你也聽說過Java中的指令重排,或是SQL的優化引擎,這些都是編譯器等等在執行程式碼之前可能會對程式碼的抽象結構做出改動以實現優化的例子。

現在的程式碼壓縮工具,還會進行更多的靜態分析以進一步壓縮程式碼,例如tree-shaking

const a = () => {};
const b = () => {};

export {
  a,
};
複製程式碼

例如對於以上的這個模組,b即沒有被匯出,也沒有在模組內被使用,因此處理後的程式碼會完全丟棄這一塊。

又例如:

const a = () => {
  const a = 3;
  const b= 1;
  return a + b;
};
複製程式碼

會被一些工具直接優化為類似如下的程式碼:

const a = () => 4;
複製程式碼

文件生成

這個例子是關於自動生成文件。

我們將使用的示例程式碼如下:

class Photo extends MediaContent {
  /**
   * @owner: User
   */
  constructor(owner) {
    this.owner = owner;
  }
  /**
   * @return: Array | 照片陣列
   */
  list() {}
  /**
   * @hash: String | 從七牛返回的hash
   * @return: Int | 新照片的id
   */
  create(hash) {}
  delete(id, useSoftDelete) {}
}

class Video extends MediaContent {
  constructor(owner) {
    this.owner = owner;
  }
  /**
   * @return: Array | 視訊陣列
   */
  list() {}
  /**
   * @url: String | 視訊的url
   * @return: Int | 新視訊的id
   */
  create(url) {}
  /**
   * @id: Int
   * @useSoftDelete: Boolean
   */
  delete(id, useSoftDelete) {}
}
複製程式碼

當我們遍歷生成的AST,我們將會看到兩個ClassDeclaration節點,每一個ClassDeclaration節點將包含類名、父類名稱(若存在)等資訊,並擁有數個MethodDefinition節點,而MethodDefinition節點會包含params列表。

不過這些資訊對於生成文件來說還顯得有些單薄。我們希望展示更多資訊,例如引數的型別和說明文字、返回值的說明等等。要從Javascript程式碼中獲取這些資訊並不容易,所以我們決定藉助於註釋。

我們需要處理的問題在於,acorn.js生成的語法樹並不包含註釋節點,我們只能通過onComment將註釋節點都收集到一個獨立的陣列中,然後根據註釋節點和方法節點的位置資訊(startend)來猜測註釋所屬的方法。

至於註釋內容的處理,可以使用正規表示式或其它任何合適的方法。

除此之外,我們還能為類和方法提供檢視原始碼的功能。

文件生成

編譯

當我們獲得了AST,我們可以重組原始碼,可以轉換為壓縮程式碼,當然也可以轉化為其它語言的程式碼,例如Python程式碼:

sum = 0
count = 1
result = None
while count <= 10:
    sum = sum + count
    count = count + 1
if count < 10:
    result = False
else:
    result = True
複製程式碼

對於demo中的程式碼,處理起來也比較簡單。這裡需要注意的點在於縮排的處理,我們需要為生成的Python程式碼保留正確的縮排。

這裡我們將改寫joinCode方法來補上空格。

const prependSpace = function prependSpace(level) {
  let str = '';
  for (let i = 0; i < level; i++) {
    str += '    ';
  }
  return str;
};

const joinCode = function joinCode(list, level = 0) {
  const space = prependSpace(level); 
  return list.filter(x => x != null).map(x => space + x).join('\n');
};
複製程式碼

我們還是來看一些典型的節點:

  WhileStatement(node, env) {
    const test = ps.process(node.test, env);
    const body = ps.process(node.body, env);
    return `while ${test}:\n${body}`;
  },
  IfStatement(node, env) {
    const test = ps.process(node.test, env);
    const consequent = ps.process(node.consequent, env);
    let str = `if ${test}:\n${consequent}`;
    const alternate = ps.process(node.alternate, env)
    if (node.alternate) str += `\nelse:\n${alternate}`;
    return str;
  },
  BinaryExpression(node, env) {
    return `${ps.process(node.left, env)} ${node.operator} ${ps.process(node.right, env)}`;
  },
複製程式碼

這裡的技巧在於維護正確的縮排層次。在什麼情況下縮排層級會改變呢?在程式碼進入和退出一個新的BlockStatement時。因此我們只需要在這裡維護層級就可以了。

事實上我們也可以在進入和退出BlockStatement時往維護變數名作用域,推入新的變數名對應表或是彈出棧頂的作用域,同時在獲取和設定變數時需要自頂向下遍歷多個對應表(不過需要注意的是不同於const和let,var並非是塊級作用域的)。不過我們的demo中並沒有設計這類問題的處理。

直譯器

接下來我們就可以試著通過遍歷AST對節點求值來執行程式碼。如果是一個AssignmentExpression我們需要設定變數,對於Identifier我們需要區分是要為其變數設定值還是取得這個變數的值,WhileStatementIfStatement同樣需要處理相應的結構:

  ...
  
  Identifier(node, env, opts) {
    if (!opts.name) return env.names[node.name];
    return node.name;
  },
  Literal(node, env) {
    return eval(node.raw);
  },
  WhileStatement(node, env) {
    while (ps.process(node.test, env)) {
      ps.process(node.body, env)
    }
  },
  IfStatement(node, env) {
    if (ps.process(node.test, env)) {
      ps.process(node.consequent, env)
    } else {
      ps.process(node.alternate, env)
    }
  },
  BinaryExpression(node, env) {
    switch (node.operator) {
      case '<=': {
        return ps.process(node.left, env) <= ps.process(node.right, env)
      }
      case '<': {
        return ps.process(node.left, env) < ps.process(node.right, env)
      }
      case '+': {
        return ps.process(node.left, env) + ps.process(node.right, env);
      }
    }
  },
  
  ...
複製程式碼

事實上任何節點都應當有返回值,例如BlockStatement的返回值應當是其最後一個節點的返回值,而IfStatement的返回值則根據其test的結果而異。不過由於我的偷懶,demo中並沒有對所有節點都進行返回,而且如前所述,我沒有處理巢狀的作用域,而是直接使用了一張表(而且還沒用Map,哈哈)。相信你能比我做的更好。

單步除錯

最後,既然我們實現了一個簡單的直譯器,那自然我們想看看是否能實現單步偵錯程式?

由於使用了JS,而且是在瀏覽器環境中,我們無法使用ptrace進行程式跟蹤,無法中斷和恢復指令,不過既然直譯器是我們自己實現的,我們還是可以做一些有趣的嘗試:

我們可以promisify所有節點的求值過程(./walker/step.js)。

IdentifierLiteral是最簡單的節點,我們可以直接使用Promise.resolve。例如BinaryExpression節點,我們需要用Promise.all等待左右子樹求職完成。稍稍麻煩的是WhileStatementBlockStatement

WhileStatement需要在其test為假之前重複執行自身節點的求值:

  WhileStatement(node, env) {
    return ps.process(node.test, env).then((flag) => {
      if (flag) {
        return ps.process(node.body, env).then(() => {
          return ps.process(node, env)
        });
      } else {
        // done
      }
    });
  },
複製程式碼

BlockStatement則需要依次處理所有節點:

  BlockStatement(node, env, opts) {
    const index = opts.index || 0;
    let item = node.body[index];
    return ps.process(item, env).then(() => {
      if (index === node.body.length - 1) {
        // done
      } else {
        return ps.process(node, env, {
          index: index + 1,
        });
      }
    });
  },
複製程式碼

在第一次編寫的時候這讓我有些暈,不過驗證了這種這一嘗試的可行性後還是覺得挺好玩兒的。

在用Promise包裹所有的求值過程之後,我們接下來只需要在需要斷點的節點型別裡劫持其resolve即可(./walker/step2.js):

  BinaryExpression(node, env) {
    return new Promise((r) => {
      env.currentNode = node;
      env.next = () => {
        r(Promise.all([
          ps.process(node.left, env),
          ps.process(node.right, env)
        ]).then((arr) => {
          const [left, right] = arr;
          switch (node.operator) {
            case '<=': {
              return left <= right;
            }
            case '<': {
              return left < right;
            }
            case '+': {
              return left + right;
            }
          }
        }));
      };
    });
  },
複製程式碼

在劫持resolve的同時,我們記錄下了當前的節點,以便高亮顯示其程式碼:

單步除錯

看著還不錯,是吧?

總結

最後,感謝你閱讀這篇文章,也希望你會覺得此文/抽象語法樹很有趣。在之後的文章裡,我會努力帶來有趣的內容,包括介紹如何使用ohm.js以及自己動手來實現語法解析。

相關文章