從零開始寫一個Javascript解析器

最近在研究 AST, 以前有一篇文章 面試官: 你瞭解過 Babel 嗎?寫過 Babel 插件嗎? 答: 沒有。卒 爲何要去了解它? 由於懂得 AST 真的能夠隨心所欲javascript

簡單點說,使用 Javascript 運行Javascript代碼。java

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

前言(若是你很清楚如何執行自定義 js 代碼,請跳過)

在你們的認知中,有幾種執行自定義腳本的方法?咱們來列舉一下:git

Web

建立 script 腳本,並插入文檔流

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

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

eval

無數人都在說,不要使用eval,雖然它能夠執行自定義腳本es6

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

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

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?express

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

相關文章
相關標籤/搜索