基于虚拟机的小程序动态 JavaScript 加载(一):背景与总览

1,225 阅读7分钟

这是一个系列的文章,通过分析 nestscript,一个基于虚拟机的 JavaScript 小程序动态执行的方案。讲述如何使用编译原理技术使得小程序可以动态执行代码,让大家理解程序编译的过程,涉及到代码语法、代码生成、虚拟机的实现原理等。可以让你对代码的运行机制,计算机的原理有更深入的了解。

背景

众所周知,原生小程序里面 JavaScript 是没办法动态加载的,微信甚至禁止了 evalnew Function 的能力。

但是这并不能阻止人们想出各种千奇百怪的方案来在小程序上做到动态加载,例如很多方案都是通过动态加载 JavaScript 文本字符串,然后通过 parser 分析成 AST,然后做一个 AST 解析运行器。

这么做会比较慢,需要在前端做 AST 的解析,而且基于 AST 的运行可以优化空间比较小。其实可以仿照成熟的编程语言的实现方式:把 AST 编译成二进制字节码,然后在前端做一个虚拟机,网络下载直接运行字节码。可以在运行时减少程序的编译成 AST 的过程。

本文已经做出一个实现 github.com/livoras/nes… 并且已经成功编译了一些经典的第三方库,例如 moment.js、lodash.js、mqtt.js,并且应用在了百万级日活的产品上。

nestscript 的实现涉及了较多的编译原理相关的知识(主要是编译后端,例如虚拟机相关),Web 开发的同学可能对这一块比较陌生。所以这系列文章除了介绍 nestscript 以外,也是由浅入深地介绍编译原理的相关知识,让大家对程序的编译过程、运行机制有更深入的认识。

因为本人水平有限,在实现的过程中也没有用到了特别专业的优化手法,有些细节可能比较粗糙。请读者见谅。如果有什么好的建议,也希望能评论指出、发 issue 和 PR 都可以。

例子:使用 nestscript 编译的开源游戏

为了展示它的效果,我们编译了一个开源的的伪 3D 游戏 javascript-racer。可以通过这个网址查看:livoras.github.io/nestscript-…

打开控制台可以看到,页面只有一个 nestscript 的虚拟机文件。然后通过网络请求下载了一个二进制文件 game,接着通过虚拟机解析、运行。

game 是个二进制文件:

这个 game 二进制文件,其实是包含了游戏的所有逻辑。是 nestscript 把原有 javascript-racer 的几个 JavaScript 文件编译而成。类似我们把 C 语言编译成的可执行的二进制文件一样,只不过它的执行不是在机器的 CPU 上执行,而是我们编写的一个虚拟机上执行。

可以对比一下,原有的游戏页面和编译后的游戏页面,效果一样:

实现的大致原理

如何把 JavaScript 语言变成可执行的二进制文件?

二进制文件就像是一个数组,它是一系列的 01010... 罢了。它是一种扁平的连续的结构,而我们 JavaScript 代码却是有很多嵌套结构的,例如函数、if else、循环等,怎么可能用一个扁平的结构来描述这么复杂多变的嵌套结构?

其实如果不知道一些计算机的原理的知识的话,确实很难理解。但实际上,任何的程序逻辑,到了 CPU 层面执行的时候,都是这么一系列可以用扁平的数组描述的指令,毕竟我们的内存也是这么一条扁平而连续的结构。CPU 只会连续地从扁平的内存一条条指令进行读取、解析、执行。所谓的虚拟机,不过就是模拟一个 CPU 执行连续指令的过程。这里不做过多的解释,具体的原理会在后续的文章中提及。

从 AST 到中间代码生成

为了让 JavaScript 更方便地变成连续的指令,我们需要设计一套没有嵌套结构、连续的中间语言,类似于汇编。我们这里也设计了一套,把它叫做 nestscript IR,例如一个计算斐波那契的程序:

function fibonacci(n) {
  if (n < 1) { return 0 }
  if (n <= 2) { return 1 }
  return fibonacci(n - 1) + fibonacci(n - 2);
}
fibonacci(10)

用 nestscript IR 来描述:

func @@main() {
    CLS @fibonacci;
    REG %r0;
    FUNC $RET @@f0;
    FUNC @fibonacci @@f0;
    MOV %r0 10;
    PUSH %r0;
    CALL_REG @fibonacci 1 false;
}
func @@f0(.n) {
    REG %r0;
    REG %r1;
    REG %r2;
    REG %r3;
    MOV %r0 .n;
    MOV %r1 1;
    LT %r0 %r1;
    JF %r0 _l1_;
    MOV %r1 0;
    MOV $RET %r1;
    RET;
    JMP _l0_;
LABEL _l1_:
LABEL _l0_:
    MOV %r0 .n;
    MOV %r1 2;
    LE %r0 %r1;
    JF %r0 _l3_;
    MOV %r1 1;
    MOV $RET %r1;
    RET;
    JMP _l2_;
LABEL _l3_:
LABEL _l2_:
    MOV %r2 .n;
    MOV %r3 1;
    SUB %r2 %r3;
    PUSH %r2;
    CALL_REG @fibonacci 1 false;
    MOV %r0 $RET;
    MOV %r2 .n;
    MOV %r3 2;
    SUB %r2 %r3;
    PUSH %r2;
    CALL_REG @fibonacci 1 false;
    MOV %r1 $RET;
    ADD %r0 %r1;
    MOV $RET %r0;
    RET;
}

里面的内容可以先不看。总直观上看出来,用 IR 表示的程序逻辑,除了函数以外,都是扁平化的。我们可以用扁平化的结构来描述任意复杂嵌套的程序逻辑。我们可以把 JavaScript 变成 AST,然后把 AST 编译成这种连续扁平的中间语言,这个过程我们把它叫代码生成(code generation)。

为什么要把 AST 变成中间代码(IR)?因为这种序列化的结构可以让我们更好地生成序列化的二进制文件。并且这种代码比较简单,结构不复杂,可以针对这种 IR 代码做代码的代码优化,让生成的二进制运行起来更高效。

中间代码生成字节码

有了代码的 IR 表示形式,就可以把经过优化的 IR 代码生成二进制,我们会把这种二进制叫做字节码。这个过程需要设计一套指令映射成二进制的方案,例如 MOV %r2 1,可以用三个字节来表示。第一个字节表示操作符,后面两个字节表示操作数。这里面的设计也非常讲究,操作数的数量会发生改变,操作数的内容长度也会发生改变。怎么让生成的代码体积更小,更紧凑,但又不影响使用是一个比较大的话题。这里面还涉及字符串,需要用特殊的段来存储字符串。这里不详细展开,会在以后的文章中提及。

虚拟机:字节码的运行

有了字节码,就可以用 JavaScript 写一个虚拟机,解析运行字节码。所谓虚拟机就是一个虚拟的 CPU,就是用代码模拟一个 CPU 解析执行二进制代码的过程。像 JavaScript 的 V8 引擎、Java、Python、Lua 都是使用了虚拟机来运行字节码。它大致的框架很简单,不过就是一个疯狂的 CPU,疯狂地循环地从字节码中取指令,识别是什么指令,然后执行不同的操作:

const byteCode: ArrayBuffer = ...

while (true) {
  const operator = nextOperator()
  switch (operator) {
    case MOV:
      ...
    case ADD:
      ...
    case SUB:
      ...
  }
}

只要你的环境中包含了这个用 JavaScript 编写的虚拟机代码,就可以通过网络下载编译好的字节码,然后让它来执行。nestscript 的虚拟机完整实现在:github.com/livoras/nes…

总结

本文只是粗略描述了 nestscript 的实现,介绍的同时给大家一个原理上的全貌,后续会慢慢展开,对各种细节进行描述。希望看完以后你也可以实现一门编程语言。

(待续未完....)


打个广告:大家可以关注一下我们的公众号,前端接龙,会分享原创的技术文章。