【译】使用 Generator 构建一个内置于浏览器中的 JavaScript 虚拟机和调试器

1285次阅读  |  发布于5年以前

原文:http://www.zcfy.cc/article/393

长文预警(tl;dr)

我用 JavaScript 创建了一个 JavaScript 虚拟机和调试器。你可以查看例子或是在 Github 下载源码。你可以继续阅读深入它的技术细节。

更新:使用 debug.js 我实现了一个 Bret Victor 的 Learnable Programming 的例子。

简介

动机

在过去的几年里,我致力于创建帮助用户在线学习编程的工具。我开发了 repl.it 并开源了它底层的技术为 udacity、codecademy、learnstreet 等在线网站提供了技术解决方案。直到最近,我加入 Codecademy 负责产品工程。经历过这一切,我最愿意看到的就是工具能够可视化地展现代码的执行过程并能够在浏览器中单步调试代码。要初步了解什么是完美的交互式在线学习环境,你可以体验 Bret Victor 的 Learnable Programming。(好东西总被墙啊,得翻墙 ╮(╯▽╰)╭----译者注)

除了对教育的好处外,这个工具如果将来成熟了,它将对代码植入、Web IDE 有帮助,而且还能为基于 JavaScript 实现其它虚拟机(有了可暂停的状态机让你不需要担心非阻塞环境)打下基础。

自从我得知了 ES6 Generators 提案,我的脑海里就一直有个不成熟的想法,却没有办法真正实现,直到 Ben Newman 发布了 Regenerator 真正把 generators 带进了浏览器。

目标

Generators

如果你对 generators 熟悉,你可以跳过这一节或者阅读这篇文章来了解更全面的内容。

Generators 是 ES6 提案的一部分,随着浏览器的升级,它已经慢慢进入生产环境。Generators 引入了一种新的函数类型,它让我们能够进入和暂时跳出一个函数,并向函数发送和接收一些值。

下面的例子将阐明 generators 的基本运作方式:

function* genFn() {
      var x = yield 2;
      yield x;
      return "done";
    }

    var gen = genFn()
    console.log(gen.next());  // {value: 2, done: false}
    console.log(gen.next(1)); // {value: 1, done: false}
    console.log(gen.next());  // {value: "done", done: true}

注意 function 关键字末尾增加一个 * 表示这是一个 generator

概述

Generators 拥有独特的能力,能让一个函数暂停执行,然后在之后的一个时间点恢复执行。Generators 的这个能力给了我们创建一个可以在指令之间单步执行,并在任何一处暂停执行的虚拟机的基本构件。要达到这个目的,系统的每一个函数都必须要被转成一个 generator,并将每一条指令执行前的状态通过 yields 传给虚拟机。这可能类似于 Continuation Passing Style,尽管如此,两者的主要区别在于 CPS 的调用栈被保存在词法作用域上,然而用我的这个方法我们需要完全控制整个调用栈。我不是一个编译器专家或者一个 PLT (Programming language theory----译者注)专家,因此我不是非常确定我的这个方法是否有名字或者是否之前有人尝试过,如果你知道,请你告诉我。

我们希望 JavaScript 宿主环境尽可能多承担运行代码的职责。除了 generator 与函数转换之外我们还需要准备一些别的东西,我会先将它们列出来,稍后一一讨论。

Code Transformation

转换代码

为了控制执行流,我们需要在每一条指令执行之后 yield 回虚拟机。要做到这个,我们在程序的每一条指令之前插入一个 yield 表达式。这里我将一条 JavaScript 语句定义成一条指令(或者说一个执行步骤)。

例如:

var foo = 1;
    if (bar === foo) {
      foo = 2;
    }

经过转换后:

function* __top() {
      yield {step};
      var foo = 1;
      yield {step};
      if (bar === foo) {
        yield {step};
        foo = 2;
      }
    }

除了基本的指令转换之外,我们需要在程序中添加关于每一个函数的信息,我们将要实现的调试器要用到那些信息。我们把这些信息叫做堆栈帧,它包含了当前函数的如下数据:

最后,函数调用处理起来比普通的指令棘手一些,因为我们需要捕获调用栈,并且还要很好地支持原生函数和库函数调用。编译时,在函数调用处,我们不知道是否函数调用引用了一个 generator 函数(一个经过我们转换的,在我们生态系统之内的函数)或者一个函数对象。如果是前者,我们需要添加它到我们的调用栈并进入函数体执行指令,如果是后者,我们只要执行它得到一个值然后返回。我们最终解决了这个难题,我们将所有的函数调用包装在一个 thunk 中然后将它 yield 回虚拟机来使得前面的问题可以在运行时决定(运行时我们能获得更多的信息)。

"Thunk" 这个词描述了 JavaScript 程序员经常做的一件事----创建一个闭包,让一段代码延迟执行。例如:

`foo();`

转换为:

yield __thunk(function *thunk() {
      return foo();
    }, this, arguments);

更复杂的调用表达式也没问题:

`for (var i = foo(), b = bar(); i < 50; i++);`

转换为:

for (var i = yield __thunk(function* thunk() {
      return foo();
    }, this, arguments), b = yield __thunk(function* thunk() {
      return bar();
    }, this, arguments); i < 50; i++);

thisarguments 被传递给 thunk 因此我们可以在 thunk 被执行时创建正确的作用域。

虚拟机

单步执行和调用栈

我们的虚拟机的主要职责是管理调用栈,压入、推出和调用其中的函数。它开始于一个停顿(或者说空闲状态)直到我们执行一个已经被转换为一个顶层的 generator 的代码字符串。然后,我们可以调用 虚拟机的 step 来执行 generator 的 next() 方法,让代码中的下一条指令执行。如果这条指令返回一个 thunk,我们执行它,如果它返回一个 generator,我们将它 push 进我们的调用栈,以备后续的执行步骤调用它。等到我们当前的 generator 执行完毕,我们获得最后的值,将它传给调用栈中的下一个 generator。传递值也是通过 generator 的 .next 方法,它接受一个参数,将这个参数传给 generator 函数。

错误处理

当执行一个指令时,有可能它会抛出一个错误。我们处理它的方式是我们 try/catch 每一条指令执行,并且如果我们获得一个错误,我们将它沿着调用栈往上传,直到其中一个调用函数有自己的 try/catch 语句。

Runner.prototype.$propError = function (e) {
      while (this.stack.length) {
        this.gen = this.stack.pop();
        try {
          this.gen.throw(e);
          return;
        } catch (e2) {
          e = e2;
        }
      }
      throw e;
    };

定时器

我们的其中一个目标是能够在任意时间点上暂停我们的程序执行,我们想要它暂停多久就暂停多久。由于这个原因,我们遇到一个问题,我们无法依赖宿主 JavaScript 环境来控制定时器。比如我们有一个 setInterval 每 1 秒钟执行一次,然而我们决定暂停程序 10 秒钟,当我们恢复程序运行时,我们不能接受 10 下连续的定时器触发。虚拟机的时钟应该只在以下情况下有效:

  1. 我们在执行指令代码。
  2. 我们处于空闲状态(调用栈空了并且虚拟机处在停顿中)。

我们使用一个优先队列来保存我们的定时器,然后用一个 tick 方法检查当前时间点是否有任何 timer 要被触发。我们依赖于宿主的 setImmedate 或者 setTimeout(tick, 0) 来提供我们需要的 tick 函数。

当一个定时器被触发,虚拟机简单发起一个 timer 事件,程序将响应这个事件进入 timer 执行(这将创建一个新的调用栈)。这个模式更像 JavaScript 的事件循环而非原生定时器。

原生 API

我们不能期望我们的代码用到的每个原生 API 都能处理我们转换过来的 generators,因此虚拟机提供了一个方法将回调包装起来,并且将它们 yield 回虚拟机以备后续执行。然而,当参数有 callback 但实际上是同步 API 的时候,比如 Array.forEach 将连续调用回调直到迭代器完成,这种情况下一个问题会产生,因为我们是期望能够暂停任何指令执行,但我们没法让 Array.forEach 这种系统原生 API 能做到同样的事情。幸运地是,这个问题不难解决,我们所要做的只是使用流行的 es5-shim 库,用我们的转换处理它,用它代替原生 API。

事件

与原生 API 的问题非常类似,事件监听是通过函数,但是这是一个非常简单的问题,因为这是异步 API,我们所需要做的仅仅是将事件监听函数用一个函数包装器包装一下,让它可以在被调用时激活我们的虚拟机。

调试器

创建了虚拟机之后,实现调试器非常有趣而且很简单。我遇到的唯一一个问题是处理调用栈中来自 thunk 的多余信息,因为我们将 thunk 当做普通函数来调用,这样让虚拟机变得简单。

特性:

在这里获得项目源码。

目前状态

这个项目还在早期开发中。我才为它工作了大约两个星期。在正确性方面,我确信虚拟机可以运行绝大多数 ES5 特性。在我写这篇文章的时候,我想起一个问题,现在这个情况下,在虚拟机里使用 getters 和 setters 肯定会有问题

这个虚拟机现在还很慢,尤其是在转换代码时,但是我们在提升速度方面马上要取得一些进展了。

我也意识到 generator 转换只是一个中间步骤,目的是让 regenerator 将代码变成能够自由地 step in/step out 的函数。因此,我们可以抛弃那个步骤,直接将代码转换成状态机。(也就是《用 JavaScript 实现单步调试》这篇文章的作者的做法,这样明显性能要好很多----译者注)。

英文原文:http://amasad.me/2014/01/06/building-an-in-browser-javascript-vm-and-debugger-using-generators/

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8