用 JavaScript 实现单步调试

617 查看

在我的上一篇文章中,我介绍了 Unwinder,它实现了 JavaScript 的“延续性(continuations)”。延续性和单步调试有什么关系呢?Unwinder 使用延续性实现了一个调试器,它可以保持状态,在任何时候暂停代码的执行。

这篇文章本来可以用“实现 JavaScript 的延续性”来作为标题,但是比起了解延续性,更多人知道的是“单步调试工具”。此外,实现一个单步调试工具,是延续性的一个非常 cool 的用途。

如果你还没有读过前一篇文章 (这篇文章深入解释了什么是延续性,并使大量用了单步调试来详细描述它),这里有一个活生生的单步调试工具:

你可以点击上面的图片链接,跳转到一个线上demo,点击任何一行代码设置一个断点

它很酷,对吗?好吧,这的确不同寻常,它不是什么从视觉上看起来有趣的东西,但是,它是一个完全用 JavaScript 实现的调试器,完全不依赖原生的 JavaScript 引擎。我的前一篇文章介绍了更多关于使用 Unwinder 的内容。

具体实现与一个复杂的变换有关,这是在一篇论文“Exceptional Continuations in JavaScript”中被提出的理论。这篇论文思路清晰且通俗易懂,如果你对它感兴趣,你可以阅读它。这项技术的亮点是性能:我们依然使用原生函数的作用域、变量和其他的部分,这样 JIT 依然可以对它们如平时一样进行深度优化。同时,代码在执行过程中被检测,从而使我们可以保存调用堆栈信息。

无论是延续性还是单步调试都需要在任意时刻可以暂停执行代码。这意味着我们需要保存整个调用栈,并且能够在需要继续运行的时候恢复它。让我们来看一下论文中描述的实现上面的需求的具体技术方案。由于论文写得非常清晰,我能够很愉快地将基本步骤写下来。

状态机

第一步,我们需要将代码编译成我们可以控制的某种结构,即将代码变成状态机,这是一种常见的变换。Facebook 的 regenerator 编译 generator、Clojure 编译 Go 语言模块,以及其他的一些编译工具,也都用了状态机。Unwinder 实际上是从 fork regenerator 的项目代码开始的。

让我们看以下代码:

将函数 foo 变换成状态机,看起来像下面这样:

$__next 是关键点:它精确控制着当前执行哪一个步骤。注意到我们将 xy 的声明放在最前面,因为我们需要重新实现变量作用域。我们将各个代码块编译进了case语句中,而函数声明被提到前面,我们还需要维持作用域并屏蔽用 varlet 或者 const 声明的同名变量。

你应该已经了解这不是一个简单的小把戏。我们才刚开始,生成的代码就已经是比原始代码复杂很多了,但是,它在小规模的程序中能很好地工作,它并不是为了真正线上产品而设计的,它只为了演示小 demo 和实现一些小功能。

重新实现原生控制结构

让我们看一些更复杂的代码,在下面的代码里我们有 while 循环,而且有同名的变量需要屏蔽:

假如我们要暂停执行在 while 循环内部的代码该怎么做?我们当然不能使用原生的 while 语句,我们必须在我们的状态机中重新实现我们的循环。一个简版的编译后的代码大概看起来如下:

这里还需注意的是,我们通过将局部的 x 变量改名为 x$0 来屏蔽函数参数里的同名变量