從零開始寫一個Javascript解析器

Axetroy發表於2018-03-09

最近在研究 AST, 之前有一篇文章 面試官: 你瞭解過 Babel 嗎?寫過 Babel 外掛嗎? 答: 沒有。卒 為什麼要去了解它? 因為懂得 AST 真的可以為所欲為

簡單點說,使用 Javascript 執行Javascript程式碼。

這篇文章來告訴你,如何寫一個最簡單的解析器。

前言(如果你很清楚如何執行自定義 js 程式碼,請跳過)

在大家的認知中,有幾種執行自定義指令碼的方法?我們來列舉一下:

Web

建立 script 指令碼,並插入文件流

function runJavascriptCode(code) {
  const script = document.createElement("script");
  script.innerText = code;
  document.body.appendChild(script);
}

runJavascriptCode("alert('hello world')");
複製程式碼

eval

無數人都在說,不要使用eval,雖然它可以執行自定義指令碼

eval("alert('hello world')");
複製程式碼

參考連結: Why is using the JavaScript eval function a bad idea?

setTimeout

setTimeout 同樣能執行,不過會把相關的操作,推到下一個事件迴圈中執行

setTimeout("console.log('hello world')");
console.log("I should run first");

// 輸出
// I should run first
// hello world'
複製程式碼

new Function

new Function("alert('hello world')")();
複製程式碼

參考連結: Are eval() and new Function() the same thing?

NodeJs

require

可以把 Javascript 程式碼寫進一個 Js 檔案,然後在其他檔案 require 它,達到執行的效果。

NodeJs 會快取模組,如果你執行 N 個這樣的檔案,可能會消耗很多記憶體. 需要執行完畢後,手動清除快取。

Vm

const vm = require("vm");

const sandbox = {
  animal: "cat",
  count: 2
};

vm.runInNewContext('count += 1; name = "kitty"', sandbox);
複製程式碼

以上方式,除了 Node 能優雅的執行以外,其他都不行,API 都需要依賴宿主環境。

直譯器用途

在能任何執行 Javascript 的程式碼的平臺,執行自定義程式碼。

比如小程式,遮蔽了以上執行自定義程式碼的途徑

那就真的不能執行自定義程式碼了嗎?

非也

工作原理

基於 AST(抽象語法樹),找到對應的物件/方法, 然後執行對應的表示式。

這怎麼說的有點繞口呢,舉個栗子console.log("hello world");

原理: 通過 AST 找到console物件,再找到它log函式,最後執行函式,引數為hello world

準備工具

  • Babylon, 用於解析程式碼,生成 AST
  • babel-types, 判斷節點型別
  • astexplorer, 隨時檢視抽象語法樹

開始擼程式碼

我們以執行console.log("hello world")為例

開啟astexplorer, 檢視對應的 AST

1

由圖中看到,我們要找到console.log("hello world"),必須要向下遍歷節點的方式,經過FileProgramExpressionStatementCallExpressionMemberExpression節點,其中涉及到IdentifierStringLiteral節點

我們先定義visitors, visitors是對於不同節點的處理方式

const visitors = {
  File(){},
  Program(){},
  ExpressionStatement(){},
  CallExpression(){},
  MemberExpression(){},
  Identifier(){},
  StringLiteral(){}
};
複製程式碼

再定義一個遍歷節點的函式

/**
 * 遍歷一個節點
 * @param {Node} node 節點物件
 * @param {*} scope 作用域
 */
function evaluate(node, scope) {
  const _evalute = visitors[node.type];
  // 如果該節點不存在處理函式,那麼丟擲錯誤
  if (!_evalute) {
    throw new Error(`Unknown visitors of ${node.type}`);
  }
  // 執行該節點對應的處理函式
  return _evalute(node, scope);
}
複製程式碼

下面是對各個節點的處理實現

const babylon = require("babylon");
const types = require("babel-types");

const visitors = {
  File(node, scope) {
    evaluate(node.program, scope);
  },
  Program(program, scope) {
    for (const node of program.body) {
      evaluate(node, scope);
    }
  },
  ExpressionStatement(node, scope) {
    return evaluate(node.expression, scope);
  },
  CallExpression(node, scope) {
    // 獲取呼叫者物件
    const func = evaluate(node.callee, scope);

    // 獲取函式的引數
    const funcArguments = node.arguments.map(arg => evaluate(arg, scope));

    // 如果是獲取屬性的話: console.log
    if (types.isMemberExpression(node.callee)) {
      const object = evaluate(node.callee.object, scope);
      return func.apply(object, funcArguments);
    }
  },
  MemberExpression(node, scope) {
    const { object, property } = node;

    // 找到對應的屬性名
    const propertyName = property.name;

    // 找對對應的物件
    const obj = evaluate(object, scope);

    // 獲取對應的值
    const target = obj[propertyName];

    // 返回這個值,如果這個值是function的話,那麼應該繫結上下文this
    return typeof target === "function" ? target.bind(obj) : target;
  },
  Identifier(node, scope) {
    // 獲取變數的值
    return scope[node.name];
  },
  StringLiteral(node) {
    return node.value;
  }
};

function evaluate(node, scope) {
  const _evalute = visitors[node.type];
  if (!_evalute) {
    throw new Error(`Unknown visitors of ${node.type}`);
  }
  // 遞迴呼叫
  return _evalute(node, scope);
}

const code = "console.log('hello world')";

// 生成AST樹
const ast = babylon.parse(code);

// 解析AST
// 需要傳入執行上下文,否則找不到``console``物件
evaluate(ast, { console: console });
複製程式碼

在 Nodejs 中執行試試看

$ node ./index.js
hello world
複製程式碼

然後我們更改下執行的程式碼 const code = "console.log(Math.pow(2, 2))";

因為上下文沒有Math物件,那麼會得出這樣的錯誤 TypeError: Cannot read property 'pow' of undefined

記得傳入上下文evaluate(ast, {console, Math});

再執行,又得出一個錯誤Error: Unknown visitors of NumericLiteral

原來Math.pow(2, 2)中的 2,是數字字面量

2

節點是NumericLiteral, 但是在visitors中,我們卻沒有定義這個節點的處理方式.

那麼我們就加上這麼個節點:

NumericLiteral(node){
    return node.value;
  }
複製程式碼

再次執行,就跟預期結果一致了

$ node ./index.js
4
複製程式碼

到這裡,已經實現了最最基本的函式呼叫了

進階

既然是直譯器,難道只能執行 hello world 嗎?顯然不是

我們來宣告個變數吧

var name = "hello world";
console.log(name);
複製程式碼

先看下 AST 結構

3

visitors中缺少VariableDeclarationVariableDeclarator節點的處理,我們給加上

VariableDeclaration(node, scope) {
    const kind = node.kind;
    for (const declartor of node.declarations) {
      const {name} = declartor.id;
      const value = declartor.init
        ? evaluate(declartor.init, scope)
        : undefined;
      scope[name] = value;
    }
  },
  VariableDeclarator(node, scope) {
    scope[node.id.name] = evaluate(node.init, scope);
  }
複製程式碼

執行下程式碼,已經列印出hello world

我們再來宣告函式

function test() {
  var name = "hello world";
  console.log(name);
}
test();
複製程式碼

根據上面的步驟,新增了幾個節點

BlockStatement(block, scope) {
    for (const node of block.body) {
      // 執行程式碼塊中的內容
      evaluate(node, scope);
    }
  },
  FunctionDeclaration(node, scope) {
    // 獲取function
    const func = visitors.FunctionExpression(node, scope);

    // 在作用域中定義function
    scope[node.id.name] = func;
  },
  FunctionExpression(node, scope) {
    // 自己構造一個function
    const func = function() {
      // TODO: 獲取函式的引數
      // 執行程式碼塊中的內容
      evaluate(node.body, scope);
    };

    // 返回這個function
    return func;
  }
複製程式碼

然後修改下CallExpression

// 如果是獲取屬性的話: console.log
if (types.isMemberExpression(node.callee)) {
  const object = evaluate(node.callee.object, scope);
  return func.apply(object, funcArguments);
} else if (types.isIdentifier(node.callee)) {
  // 新增
  func.apply(scope, funcArguments); // 新增
}
複製程式碼

執行也能過列印出hello world

完整示例程式碼

其他

限於篇幅,我不會講怎麼處理所有的節點,以上已經講解了基本的原理。

對於其他節點,你依舊可以這麼來,其中需要注意的是: 上文中,作用域我統一用了一個 scope,沒有父級/子級作用域之分

也就意味著這樣的程式碼是可以執行的

var a = 1;
function test() {
  var b = 2;
}
test();
console.log(b); // 2
複製程式碼

處理方法: 在遞迴 AST 樹的時候,遇到一些會產生子作用域的節點,應該使用新的作用域,比如說functionfor in

最後

以上只是一個簡單的模型,它連玩具都算不上,依舊有很多的坑。比如:

  • 變數提升, 作用域應該有預解析階段
  • 作用域有很多問題
  • 特定節點,必須巢狀在某節點下。比如 super()就必須在 Class 節點內,無論巢狀多少層
  • this 繫結
  • ...

連續幾個晚上的熬夜之後,我寫了一個比較完善的庫vm.js,基於jsjs修改而來,站在巨人的肩膀上。

與它不同的是:

  • 重構了遞迴方式,解決了一些沒法解決的問題
  • 修復了多項 bug
  • 新增了測試用例
  • 支援 es6 以及其他語法糖

目前正在開發中, 等待更加完善之後,會發布第一個版本。

歡迎大佬們拍磚和 PR.

小程式今後變成大程式,業務程式碼通過 Websocket 推送過來執行,小程式原始碼只是一個空殼,想想都刺激.

專案地址: github.com/axetroy/vm.…

線上預覽: axetroy.github.io/vm.js/

原文: axetroy.xyz/#/post/172

相關文章