A script nested in JavaScript, dynamically runs code in environment without eval
and new Function
.
nestscript
可以让你在没有 eval
和 new Function
的 JavaScript 环境中运行二进制指令文件。
原理上就是把 JavaScript 先编译成 nestscript
的 IR 指令,然后把指令编译成二进制的文件。只要在环境中引入使用 JavaScript 编写的 nestscript
的虚拟机,都可以执行 nestscript
的二进制文件。你可以把它用在 Web 前端、微信小程序等场景。
它包含三部分:
- 代码生成器:将 JavaScript 编译成
nestscript
中间指令。 - 汇编器:将中间指令编译成可运行在
nestscript
虚拟机的二进制文件。 - 虚拟机:执行汇编器生成的二进制文件。
理论上你可以将任意语言编译成 nestscript
指令集,但是目前 nestscript
只包含了一个代码生成器,目前支持将 JavaScript 编译成 nestscript
指令。
目前支持单文件 ES5 编译,并且已经成功编译并运行一些经典的 JavaScript 第三方库,例如 moment.js、lodash.js、mqtt.js。并且有日活百万级产品在生产环境使用。
npm install nestscript
console.log("hello world")
npx nsc compile main.js main
这会将 main.js
编译成 main
二进制文件
npx nsc run main
会看到终端输出 hello world
。这个 main
二进制文件,可以在任何一个包含了 nestscript 虚拟机,也就是 dist/vm.js
文件的环境中运行。
例如你可以把这个 main
二进制分发到 CND,然后通过网络下载到包含 dist/vm.js
文件的小程序中动态执行。
为了展示它的作用,我们编译了一个开源的的伪 3D 游戏 javascript-racer。可以通过这个网址查看效果:https://livoras.github.io/nestscript-demo/index.html
查看源代码(nestscript-demo)可以看到,我们在网页中引入了一个虚拟机 vm.js
。游戏的主体逻辑都通过 nestscript 编译成了一个二进制文件 game
,然后通过 fetch
下载这个二进制文件,然后给到虚拟机解析、运行。
<!-- 引入 nestscript 虚拟机 -->
<script src="./nestscript/dist/vm.js"></script>
<!-- 下载二进制文件 `game`,并且用虚拟机运行 -->
<script>
fetch('./game').then((res) => {
res.arrayBuffer().then((data) => {
const vm = createVMFromArrayBuffer(data, window)
vm.run()
})
})
</script>
达到的效果和原来的开源的游戏效果完全一致
- 原来用 JS 运行的效果:http://codeincomplete.com/projects/racer/v4.final.html
- 用虚拟机运行 nestscript 二进制的效果:https://livoras.github.io/nestscript-demo/index.html
编译的过程非常简单,只不过是把原来游戏的几个逻辑文件合并在一起:
cat common.js stats.js main.js > game.js
然后用 nestscript 编译成二进制文件:
nsc compile game.js game
再在 html 中引入虚拟机 vm.js,然后通过网络请求获取 game 二进制文件,接着运行二进制:
<!-- 引入 nestscript 虚拟机 -->
<script src="./nestscript/dist/vm.js"></script>
<!-- 下载二进制文件 `game`,并且用虚拟机运行 -->
<script>
fetch('./game').then((res) => {
res.arrayBuffer().then((data) => {
const vm = createVMFromArrayBuffer(data, window)
vm.run()
})
})
</script>
把 JavaScript 文件编译成二进制,例如 npx nsc compile game.js game
。注意,目前仅支持 ES5 的语法。
通过 nestscript 虚拟机运行编译好的二进制,例如 npx nsc run game
由 dist/vm.js
提供的方法,解析编译好的二进制文件,返回一个虚拟机实例,并且可以准备执行。例如:
const vm = createVMFromArrayBuffer(buffer, context)
buffer
指的是二进制用 JavaScript 的 ArrayBuffer 的展示形式;
context
相当于传给虚拟机的一个全局运行环境,因为虚拟机的运行环境和外部分离开来的。它对 window、global 这些已有的 JavaScript 环境无知,所以需要手动传入一个 context
来告知虚拟机目前的全局环境。虚拟机的全局变量、属性都会从传入的 contenxt
中拿到。
例如,如果代码只用到全局的 Date
属性,那么除了可以直接传入 window
对象以外,还可以这么做:
const vm = createVMFromArrayBuffer(buffer, { Date })
createVMFromArrayBuffer
返回的虚拟机实例有 run
方法可以运行代码:
const vm = createVMFromArrayBuffer(buffer, { Date })
vm.run()
MOV, ADD, SUB, MUL, DIV, MOD,
EXP, NEG, INC, DEC,
LT, GT, EQ, LE, GE, NE,
LG_AND, LG_OR, XOR, NOT, SHL, SHR,
JMP, JE, JNE, JG, JL, JIF, JF,
JGE, JLE, PUSH, POP, CALL, PRINT,
RET, PAUSE, EXIT,
CALL_CTX, CALL_VAR, CALL_REG, MOV_CTX, MOV_PROP,
SET_CTX,
NEW_OBJ, NEW_ARR, SET_KEY,
FUNC, ALLOC,
详情请见 nestscript 指令集手册。
例如使用指令编写的,斐波那契数列:
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;
}
-
export
,import
模块支持 -
class
支持 - 中间代码优化
- 基本中间代码优化
- 属性访问优化
- 文档:
- IR 指令手册
- 安装文档
- 使用手册
- 使用 demo
-
null
,undefined
keyword - 正则表达式
- label 语法
-
try catch
- try catch block
- error 对象获取
- ForInStatement
- 支持 function.length
- 函数调用的时候延迟 new Scope 和 scope.fork 可以很好提升性能(~500ms)
- 性能优化:不使用
XXXBuffer.set
从 buffer 读取指令速度更快
- 解决 try catch 调用栈退出到 catch 的函数的地方
- 重新设计闭包、普通变量的实现方式,使用 scope chain、block chain
- 实现块级作用域
- 使用块级作用域实现
error
参数在catch
的使用
- 闭包的形式应该是:
- FUNC 每次都返回一个新的函数,并且记录上一层的 closure table
- 调用的时候根据旧的 closure table 构建新的 closure table
- fix 闭包生成的顺序问题
- 编译第三方库 moment.js, moment.min.js, lodash.js, lodash.min.js 成功并把编译加入测试
- 编译 moment.js 成功
- fix if else 语句的顺序问题
- ForInStatement
- 编译 lodash 成功
arguments
参数支持
- fix 闭包问题
- 继续编译 lodash:发现没有 try catch 的实现
- 继续编译 lodash,发现了运行时闭包声明顺序导致无法获取闭包的 bug
- 编译 lodash 成功(运行失败)
- 给函数参数增加闭包声明
- UpdateExpression 的前后缀表达存储
- 完成 label 语法:循环、block label
null
,undefined
- 正则表达式字面量
while
,do while
,continue
codegen- 更多测试
- 第一版 optimizer 完成
- 设计代码优化器的流程图
- 把操作数的字节数存放在类型的字节末端,让操作数的字节数量可以动态获取
- 对于 Number 类型使用 Float64 来存储,对于其他类型的操作数用 Int32 存储
- 可以较好地压缩二进制程序的大小.
- 重新设计操作数的生成规则
- 函数的调用有几种情况
- vm 调用自身函数
- vm 调用外部函数
- 外部调用 vm 的函数
- 所以:
- vm 在调用函数的时候需要区分是那个环境的函数(函数包装 + instanceof)
- 如果是自身的函数,不需要对参数进行操作
- 如果是外部函数,需要把已经入栈的函数出栈再传给外部函数
- 内部函数在被调用的时候,需要区分是那个环境调用的(NumArgs 包装 + instanceof)
- 如果来自己的调用,不需要进行特别的操作
- 如果是来自外部的调用,需要把参数入栈,并且要嵌入内部循环等待虚拟机函数结束以后再返回
- vm 在调用函数的时候需要区分是那个环境的函数(函数包装 + instanceof)
- 让函数可以正确绑定 this,不管是 vm 内部还是外部的
- 代码生成中表达式结果的处理原则:所有没有向下一层传递 s.r0 的都要处理 s.r0
- 三目运算符 A ? B : C 的 codegen(复用 IfStatement)
- 自动包装 @@main 函数,这样就不需要主动提供 main 函数,更接近 JS
- 更多测试
- 完成 +=, -=, *=, /= 操作符
- 新增测试 & CI
- uinary expression 的实现:+, -, ~, !, void, delete
- 逻辑表达式 "&&" 和 "||" 的 codegen 和虚拟机实现
- 完成闭包在虚拟机中的实现
- 完成闭包变量的标记方式:内层函数“污染”外层的方式
- 重构代码生成的方式,使用函数数组延迟代码生成,这样可以在标记完闭包变量以后再进行 codegen
- 设计闭包变量和普通变量的标记方式“@xxx”表示闭包变量,“%xxx”表示普通自动生成的寄存器
- 下一步设计闭包变量在汇编器和虚拟机中的生成和调取机制
- 设计闭包的实现
- 实现
CALL_REG R0 3
指令,可以把函数缓存到变量中随后进行调用
- 支持回调函数的 codegen
- 完成基本的 JS -> 汇编的转化和运行
- 循环 codegen
- 三种值的更新和获取
- Identifier
- context
- variables
- Memeber
- Identifier
- 完成二进制表达式的 codegen
- 完成简单的赋值语句
- 完成对象属性访问的 codegen
- if 语句的 codegen
- 确定使用动态分配 & 释放寄存器方案
- 表达式计算值存储到寄存器方案,寄存器名称外层生成、传入、释放
- 开始使用 acorn 解析 ast,准备把 ast 编译成 IR 指令
FUNC R0 sayHi
: 构建 JS 函数封装sayHi
函数并存放到 R0 寄存器,可以用作 JS 的回调参数,见example/callback.nes
CALL_VAR R0 "forEach" 1
: 调用寄存器里面存值的某个方法MOV_PROP R0 R1 "length"
: 将 R1 寄存器的值的 "length" 的值放到 R0 寄存器中
- 完成
NEW_ARR R0
: 字面量数组NEW_OBJ R0
: 字面量对象SET_KEY R0 "0" "hello"
: 设置某个寄存器里面的 key value 值CALL_CTX "console" "log" 1
: 调用 ctx 里面的某个函数方法MOV_CTX R0 "console.log"
: 把 ctx 某个值移动到寄存器
- 完成基本的汇编器和虚拟机
- 完成命令行工具 nsc,可以
nsc compile src dest
将文本代码 -> 二进制文件,并且用nsc run file
执行 - 斐波那契数列计算例子
- 编译打包成第三方包