【CSDN 编者按】在编程世界里,我们常常被突如其来的灵感所驱动,迫不及待地想要将那些新奇的想法转化为现实——本文作者便是如此。他深入探讨了一项颇具创意的技术实验:将 JavaScript 转译为 C++,并进一步编译为目标代码。这一过程中,作者不仅面对了实现层面的重重困难,也经历了一段充满曲折的心路历程。
原文链接:https://surma.dev/things/compile-js/
作者 | Das Surma
翻译 | 郑丽媛
出品 | CSDN(ID:CSDNnews)
有时候,我会对某个突如其来的想法非常上头,以至于忘了问自己,这个想法到底值不值得实现。总之,我这次的想法是:将 JavaScript 转译成 C++,然后再将其进一步编译成所有需要的目标代码。
最终,我得出结论:我认为这种特定的方式不值得再深入探索。这也导致我写这篇博客的时候,遇到了许多困难,因为我走了很多捷径,引入了很多缺陷,所以我很难讲述一个连贯且清晰的故事。即便如此,我认为发表一篇略显仓促的博客文章来解释我的思考过程并分享所学到的经验教训,总比什么都不写要好——所以,就这样开始吧!
概念验证的实现,以及这篇博客文章,都遵循了渐进式的设计原则。我走了很多捷径,所以这个系统的很多部分都没有完成,因为我优先考虑的是先大体完成它。尽管如此,我还是希望这篇文章能给你带来一些有趣的东西。
初始动机
虽然我的探索结果并不特定于 WebAssembly(事实上,不涉及 WebAssembly 效果可能更好),但我最初的动机确实是:在 WebAssembly 中运行 JavaScript。
在工作中,我一直在研究 Shopify Functions。简单来说,Shopify Functions 允许开发者在 Shopify 服务器上运行自己的代码,并与 Shopify 的其他业务逻辑紧密集成,从而实现对 Shopify 平台的深度定制,包括性能关键路径部分的自定义。在电商领域,安全性和性能至关重要,而 WebAssembly 以其可预测的性能和强大的沙箱机制成为关键技术的基础组件。理论上来说,第三方开发者可以使用任何语言编写任意代码片段,而 Shopify 可以控制这些代码片段如何影响系统的其余部分。Shopify 接受任何兼容 WASI 的 WebAssembly 模块,只要模块大小不超过 250KB。
在撰写本文时,所有 Shopify 提供的 WebAssembly 扩展点都采用了“JSON 输入,JSON 输出”的架构。作为一名 Web 开发者,我很想用 JavaScript 编写我的 Shopify Functions——但是,JavaScript 并不能直接编译成 WebAssembly。
不过,它真的不能吗?
简化 JavaScript 在 WebAssembly 中的运行
要让 JavaScript 在 WebAssembly 中运行,一个解决方案是将 JS 引擎编译成 Wasm,让它解析并执行你的 JS 代码。然而,像 V8 或 SpiderMonkey 这样的大型 JS 引擎很难被编译为 Wasm,更不用说 JIT(即时编译)作为一种概念目前在 Wasm 中还不可能实现。尽管如此,ByteCodeAlliance 还是成功地把 SpiderMonkey 编译成 WebAssembly,但我确信它的输出模块不会很小。
JIT 编译:WebAssembly的设计原则是不可变地存储指令,并且与这些指令所操作的内存分离。这意味着,至少到目前为止,Wasm 模块不能生成新的指令并立即执行它们。
因此,我开始关注 JS 解释器和虚拟机。Shopify Functions 团队创建了名为 javy 的工具链,它能将一个 JS 虚拟机编译为 Wasm,并将你的 JS 代码嵌入到 Wasm 模块中。javy 所依赖的引擎是 QuickJS,这是一个完全符合 ES2015 标准的小型 JavaScript 虚拟机,由 Fabrice Bellard 编写,同时他也是 qemu、ffmpeg 和 tcc 的创造者。不过,最终编译出的 Wasm 模块略大于 250KB。这个尺寸已经非常接近限制值,于是我尝试移除 JS 解析器,仅编译 QuickJS 的字节码 VM 部分,可惜没有成功。即便移除了未使用的全局对象(如 ArrayBuffer 或 Symbol),也没有让我的模块大小降到限制以下。
Shopify Functions 团队正在研究一种允许用 JavaScript 编写函数的方法。在此期间,我也会在这篇博客的剩余部分探索一种不那么严肃的解决方案。
C++
C++ 是一种能非常高效地编译到 WebAssembly (Wasm) 的编程语言。在 Wasm 工具链的早期阶段,大部分工作都集中在让 C++ 代码能够在网页上运行,因为很多大型软件项目的核心都是基于 C++ 构建的。如今,LLVM 的 clang++ 编译器已经原生支持 WebAssembly,WASI-SDK 也提供了一个针对 WASI(WebAssembly System Interface)而非 POSIX 环境的 sysroot(包括 libc, libc++ 等),这使得我们可以将 C/C++ 代码编译为 WebAssembly,并在任何与 WASI 兼容的环境(如 wasmtime)中运行它。
现在,终于到了我的这篇博客文章所要探讨的、稍显业余的观察:我认为 JavaScript 看起来很像 C++。事实上,JavaScript 所提供的大部分功能,在 C++20 中也都有提供,而且往往语法极其相似。如果我能编写一种转译器,将 JavaScript 转换为 C++,同时保持 JavaScript 的语义和行为,那会怎样呢?我能否编写一个非常简单的转译器,将所有困难的工作(如类型检查和作用域管理)都交给 C++ 编译器来处理(主要是因为我缺乏构建编译器的经验)?这样做会不会生成更小的二进制文件?甚至可能更快的执行速度?嗯,只有一种方法可以找出答案——动手试试看。
为了明确我的玩具转译器应该具备哪些功能,我编写了一个相当复杂的 JavaScript 程序,类似于下面的示例:
function* numbers() {
let i = 0;
const f = () => i++;
yield* [f(),f(),f()].map(i => i + 1);
}
const arr = [];
for(let x of numbers()) {
arr.push(x);
}
IO.write_to_stdout(arr.join(","));
当然,这个程序毫无意义,但它却涵盖了我希望支持的一系列特性:变量、函数、输出、循环、迭代器、生成器、闭包、方法等……而且,它的输出是确定且定义明确的。
概念验证(Proof-of-Concept)
我们先来进行概念验证(Proof-of-Concept,简称 PoC)。我把这次探索命名为 jsxx,你可以在我的 GitHub 上找到所有源代码。不过先提醒一下:这是我第一次使用 C++20。很多年前,我曾写过 C++,那时 C++11 还被认为是前沿技术。在从事微处理器工作时,我也写了很多 C 代码,这在我的编码风格中仍然有所体现。如今,我主要写 JavaScript 和 Rust。这次我借此机会恶补了一下 C++,并对 C++20 提供的新特性有了更深入的了解。当我询问相关建议时,Sy Brand 推荐了 Josh Lospinoso 的《C++ 速成教程》一书,我已经读过了,觉得非常不错,现在也强烈推荐给大家。
话说回来,我确信我的 C++ 代码可能写得一塌糊涂,所以请不要看得太仔细。
使用 JSXX
这个转换器的用户界面设计得相当简洁,但功能强大。例如,使用上面提到的目标程序,你可以通过以下命令将 JavaScript 编译为 C++,并立即调用 clang++ 将其转换成本地二进制文件:
$ cat testprog.js | cargo run
$ ./output
1.000000,2.000000,3.000000
要编译为 WebAssembly,可以使用 --wasm 标志,并提供指向 WASI-SDK 的 clang++ 路径(以及任何所需的额外编译器标志):
$ cat testprog.js | \
cargo run -- \
--wasm \
--clang-path $HOME/Downloads/wasi-sdk-16.0/bin/clang++ \
-- -Oz -flto -Wl,--lto-O3
$ wasmtime output.wasm
1.000000,2.000000,3.000000
$ ls -alh output.wasm
-rwxr-xr-x 1 surma staff 86K Sep 29 19:05 output.wasm
$ cat output.wasm | brotli -q 11 -c | wc -c
29972
因此,我成功地在没有编写整个引擎的情况下运行了一些相对复杂的 JavaScript 代码,并且最终得到了一个仅 86KiB 大小的 WebAssembly 文件(经 Brotli 压缩后约为 30KiB)。这真是太酷了!
如果你想查看生成的 C++ 代码,可以通过传递 --emit-cpp 标志来实现。
不过,我认为这种特定的方法不值得进一步探索。为了解释我这么想的原因,我想我应该先介绍一下这种方法的工作原理。
JSXX
让我们从这个设置中最常规的部分开始:解析器。
(1)解析器
我不想自己编写解析器,因为这不是这个项目中最有意思的部分。由于我打算用 Rust 来编写这个转换器,所以我决定从 swc 中直接提取解析器和抽象语法树(AST)。这样,我就可以解析最新的 ES2022 语法了。我的目标是利用 JavaScript 和 C++ 之间的相似性,来保持转译器的极度简化。它所做的只是遍历 JavaScript 的 AST,并在一次遍历中生成相应的 C++ 代码,而无需跟踪变量作用域、类型或任何复杂的编译器相关的内容。大部分工作都会落在我将要编写的运行时上。这一步的核心原则是:代码不需要漂亮,只需要能够编译通过即可。
(2)变量
在 JavaScript 中,变量可以包含任何基本值类型:布尔值(bool)、数字(number)、字符串(string)、函数(Function)、对象(Object)或数组(Array)。技术上还有更多基本类型,如 Symbol 或 BigInt,但我不打算实现这些。
从语法层面来看,将这些转换为 C++ 相对简单,特别是自从 C++ 引入了用于变量声明的 auto 关键字之后。然而,C++ 要求变量必须有一个确定的类型,而 JavaScript 中的变量可以根据需要随意改变其类型。将数字和字符串分配给同一个变量在 JavaScript 中很常见,但对于 C++ 的类型系统来说却是个问题。因此,我需要引入一种能够存储任意 JavaScript 值的类型。这个类型最终变成了名为 JSValue 的类。在我们深入探讨这个类的内部结构之前,单是这个名字就足以完成对变量声明的转译了。
let x = 4;
x = "hello";
……这段 JavaScript 代码可以转译为如下 C++ 代码:
auto x = JSValue{4};
x = JSValue{"hello"};
如果我在写 C 语言代码时,希望一个变量能够包含多种类型的数据,我会使用联合体(union),但联合体是出了名的类型不安全。幸运的是,C++ 提供了类型安全的 union 替代品——std::variant:
#include
class JSValue {
using Box = std::variant
JSNumber,
JSString,
JSFunction,
JSArray,
JSObject>;
// ...
Box box;
}
通过 jsValue.box.index(),我们可以查询底层值的类型。使用 std::get
(jsValue.box) ,我们可以访问底层的实际值。如果我们使用std::get时指定了错误的类型,则会抛出异常。
(3)原始类型
大多数 JavaScript 的原始类型在 C++ 中都有直接的对应类型。例如,JavaScript 中的 number 类型可以映射为 C++ 的 double,而 JavaScript 的 string 则可以映射为 C++ 的 std::string(我们暂时忽略关于 UTF-16 编码和 C++ 字符串编码细节的问题)。然而,我决定为每个 C++ 原始类型封装在一个自定义类中,因为我知道迟早得给它们添加像 .toString() 这样的方法,而这需要通过类来实现。
简单来说,JSArray 就是一个 JSValue 的向量:
class JSArray {
// ...
std::vector
internal;
}
JSObject 实现为一个键值对的列表。虽然哈希映射(如 std::unordered_map 或 std::map)也是一种可行的选择(并且可能更快),但 JavaScript 规范要求对象属性的添加顺序必须在迭代时得到保留。此外,我在尝试让 JSValue 作为 std::map 的键时遇到了困难。
class JSObject {
// ...
std::vector
> internal;
}
接下来,我们可以利用 C++ 强大的操作符重载功能,精确地定义当一个 JSValue 被赋值给另一个 JSValue 时会发生什么。正如你印象中那样,JavaScript 中的一些类型在此处会表现出与其他类型不同的行为。
(4)引用与值
在 JavaScript 中,某些原始类型是以引用形式传递的,而另一些则是以值的形式传递。具体来说,bool、number 和 string 是按值传递的,这意味着当它们被赋值给另一个变量或作为函数参数传递时,实际上会创建一个新的副本。而其他类型的对象(如 Object、Array)则按引用传递,即两个变量可以指向同一个底层对象。
为了在 C++ 中模仿这种行为,我不得不开始在堆上分配对象和数组,以便它们的生命周期可以与创建它们的函数关联起来。但是,一旦开始在堆上分配东西,你就必须考虑如何释放这些内存。为了避免引入一个完整的垃圾收集器(毕竟,保持代码量小是这次设计的一个动机),我决定使用 std::shared_ptr,这是一个带有引用计数的指针包装器。当引用计数器达到零时,堆上的内存就会被释放。虽然这种方法可以正确处理大多数场景,但对于循环数据结构,内存将永远不会被释放,从而导致内存泄漏。唉,不过这也是没办法的事。
class JSValue {
using Box = std::variant
JSNumber,
JSString,
JSFunction,
JSArray,
JSObject>;
std::shared_ptr
,
std::shared_ptr
>;
// ...
Box box;
}
通过上述实现,C++ 的默认赋值操作符现在能够正确处理不同类型的 JSValue:布尔值、数字和字符串会被复制,而数组和对象则会复制引用,这意味着赋值后两个变量将指向同一个底层值。
(5)操作符与类型转换
为了保持转译器的简单性,我不想追踪每个变量的类型。因此,在转译像 a + b 这样的表达式时,不能依赖 a 或 b 的类型。相反,我选择在 JSValue 上重载所有操作符,并在运行时进行类型检查。虽然这是一段相对枯燥的代码,但它的逻辑是清晰的:它会检查左侧(LHS)和右侧(RHS)的操作数类型,然后根据类型执行相应的操作。
举个例子,下面展示了加法运算符 (+) 的实现方式:
class JSValue {
JSValue JSValue::operator+(JSValue other) {
if (this->type() == JSValueType::NUMBER) {
return JSValue{std::get
(this->box).internal +
other.coerce_to_double()};
}
if (this->type() == JSValueType::STRING) {
return JSValue{std::get
(this->box).internal +
other.coerce_to_string()};
}
return JSValue{"Addition not implemented for this type yet"};
}
}
如果左侧操作数(LHS)是一个数字,我会从 Box 中提取底层的 double 类型,并将右侧操作数(RHS)强制转换为 double。然后,我就可以像 C++ 原本设计的那样,将两个双精度浮点数相加,并将结果转换回 JSValue 对象。对于字符串的处理流程也是这样。不过,编写了几个这样的运算符重载之后,我开始觉得这种代码写起来非常繁琐且重复。如果你现在尝试使用 jsxx,它确实允许你添加变量,但如果尝试减去变量,则会抛出异常。至于除法操作,想都不要想。
coerce_to_double() 等函数实际上是一系列的 if-else 语句链,其中包含了 JavaScript 的类型转换逻辑,比如将布尔值 true 转换为 1.0 等。
(6)数组
数组的处理比我预期的要简单得多。我只需要为 JSValue 添加一个特殊的构造函数(即静态方法),并在遇到 JavaScript 数组字面量时让转译器生成调用此方法的代码。
例如,下面这段 JavaScript 代码:
let x = [1, 2, 3]
会被转译成如下 C++ 代码:
auto x = JSValue::new_array({JSValue{1}, JSValue{2}, JSValue{3}});
虽然生成的 C++ 代码不如原生 JavaScript 那么简洁,但考虑到 C++ 代码是用于编译而非直接阅读的,所以这样的转换也可以接受。
(7)对象
对于对象,我也采用了类似的处理方式:我为 JSValue 添加了一个特殊的构造函数,并在转译器中增加了一些逻辑来处理所有特殊的属性表示法(键值对、简写属性、getter、setter、方法……)。
例如,下面这段 JavaScript 代码:
let x = {
a: 1,
b: "hello"
};`
会被转译成如下 C++ 代码:
auto x = JSValue::new_object({
{JSValue{"a"}, JSValue{1}},
{JSValue{"b"}, JSValue{"hello"}},
});
我提到了方法,但还没有真正谈到闭包。
(8)函数与闭包
在 C++ 中,如果你想将函数作为值传递,那么必须使用 std::function 作为类型。由于 JavaScript 中的所有函数实际上都是闭包,因此我决定在 C++ 中也使用闭包。如果你不熟悉 C++ 闭包的语法,可能会觉得它有点奇怪,那么让我快速为你讲解一下。以下是一个 C++ 闭包的示例:
auto my_closure = [=](JSValue parameterA, JSValue parameterB) mutable -> JSValue {
// ...
}
在括号中,我们定义了闭包的参数,箭头->定义了闭包的返回类型。这里的 mutable 关键字在我们的上下文中是必要的,因为 C++ 闭包默认以 const 方式捕获变量,意味着它们不能被修改。而在 JavaScript 闭包中,可以捕获并修改函数作用域之外的变量,所以我们需要使用 mutable。在方括号 [] 中,你可以定义这个闭包捕获哪些变量以及捕获方式。为什么 C++ 要在闭包定义的两个位置中分开指定捕获方式呢?我不知道,但你可以为每个变量定义不同的捕获风格。例如,[a, &b, &c, d] 会按值捕获 a 和 d,而按引用捕获 b 和 c。
如果我想列出每个捕获的变量,那么我需要在转译器中实现词法作用域的理解,这同样会带来过多的复杂性。幸运的是,C++ 还允许我定义一个默认捕获类型,该类型适用于所有未明确列出的变量。[&] 将默认捕获方式设置为引用,而 [=] 则设置为按值复制。
按引用捕获实际上不是一个好选择,因为它会再次将引用的生命周期绑定到创建函数上。而按值复制也不是一个完美的解决方案,因为我最终会得到一个副本。因此,我的解决方案是在 JSValue 的基础上再加一层 shared_ptr,这样复制 JSValue 时就相当于复制了指向同一数据的指针。同时,我还为 JSValue 添加了一个 .boxed_value() 方法,确保在需要的时候能够获取到值的真实副本,比如处理基本类型如布尔值、数字和字符串时。
class JSValue {
using Box = std::variant
std::shared_ptr
,
std::shared_ptr
>;
// ...
Box box;
shared_ptr
box;
}
另外,关于闭包还有两件事值得注意:一是 JavaScript 的每个闭包都有一个 this 值(包括箭头函数,它们继承了外围作用域中的 this 值);二是 JavaScript 函数可以接受不定数量的参数。鉴于 C++ 函数通常有固定的参数列表(尽管 C++ 支持可变参数函数,但这不是常规用法),我选择将所有闭包转换为带有两个参数的 C++ 闭包:一个是 JSValue thisArg,代表 this 值;另一个是 std::vector
& args,用来接收函数调用时传递的所有参数。
(9)属性
我得承认:我并不想实现完整的原型链机制,但我确实需要一种方法来定义基本类型上的方法、getter 和其他属性,以便像 myArray.length 这样的逻辑有地方可以存放。于是,我决定为每个原始类型类(如 JSBool、JSNumber 等)提供一个共享的基础类 JSBase,它提供了一个键值映射列表,我将其解释为属性。每个原始类型类的构造函数都会将预期的函数和 getter/setter 放入其继承的属性映射中。以下是 JSArray 构造函数的一个示例:
JSValue JSArray::push_impl(JSValue thisArg, std::vector
&args) {
auto arr = std::get
(thisArg->boxed_value());
for (auto v : args) {
arr->internal->push_back(v);
}
return JSValue::undefined();
}
// ...
std::vector
> JSArray_prototype{
{JSValue{"push"}, JSValue::new_function(&JSArray::push_impl)},
{JSValue{"map"}, JSValue::new_function(&JSArray::map_impl)},
{JSValue{"filter"}, JSValue::new_function(&JSArray::filter_impl)},
{JSValue{"reduce"}, JSValue::new_function(&JSArray::reduce_impl)},
{JSValue{"join"}, JSValue::new_function(&JSArray::join_impl)},
};
JSArray::JSArray() : JSBase(), internal{new std::vector
{}} {
for (const auto &entry : JSArray_prototype) {
this->properties.push_back(entry);
}
// Create a getter-only prop for `length`
auto length_prop = JSValue::with_getter_setter(
JSValue::new_function(
[=](JSValue thisArg, std::vector
&args) mutable -> JSValue {
auto arr = std::get
(thisArg->boxed_value());
return JSValue{arr->internal.size()};
}),
JSValue::undefined() // No setter (for now)
);
this->properties.push_back({JSValue{"length"}, length_prop});
};
从上面的例子可以看出,我采取了许多捷径。我只实现了数组方法的一个子集,并且暂时没有为 .length 实现 setter。这主要是为了简化实现过程,同时满足当前的需求。
(10)控制结构
在处理 if、for、while 等控制结构时,这些语句基本上是按照 1:1 的比例直接转译的。唯一的注意事项是 C++ 中的 if 语句期望的是一个布尔值(bool),而不是 JSValue,因此转译器会在每个条件表达式后面添加 .coerce_to_bool() 方法来确保类型匹配。
(11)异常处理
异常处理机制在 JavaScript 和 C++ 中都非常相似:两者都使用 try{...} catch{...} 来捕获异常。虽然我并没有实现对 JavaScript中finally{...} 的支持,但这种映射关系非常直观。值得注意的是,尽管 WebAssembly 已经原生支持异常处理,但 WASI-SDK 目前还不支持 C++ 异常。这可能是因为 Emscripten 团队需要将他们对 libunwind 的补丁合并到上游项目中。
(12)迭代器
考虑到 JSON 处理的需求,我希望能够在数组或对象上进行迭代。JavaScript 提供了 for-of 循环,用于遍历实现了迭代协议的对象。而在 C++ 中,任何类型如果具有 begin() 和 end() 方法,并且这两个方法返回的对象重载了解引用操作符(*it)、后置自增操作符(it++)以及比较操作符(it1 == it2),那么该类型就是可迭代的。有了这些,大多数标准库函数如 std::for_each 或范围 for 循环(for(auto item : array) { ... })都可以正常工作。
语法上的转换相对简单,核心工作在于实现 C++ 的迭代协议,并构建一个适配器以连接到 JavaScript 的迭代协议。具体来说,就是在 JSValue 中添加 begin() 和 end() 方法,检查 JSValue 是否拥有 Symbol.iterator 属性,如果有,则调用它。
理论上,我可以直接用纯 C++ 实现数组的迭代函数,但由于我打算学习 C++20 协程以添加对生成器的支持,所以我选择了使用协程来实现。
(13)协程
C++20 引入了无栈协程的支持。协程,就像 JavaScript 中的生成器(generators)一样,是一种可以暂停和恢复执行的特殊函数。如果 C++ 编译器在你的代码中遇到协程,它会创建一个数据结构来保存所有必要的状态,并会帮你将这些状态存储在堆上(因此称为“无栈”)。在恢复协程执行时,它会恢复状态,并从之前中断的地方继续运行该函数。老实说,这个协议(即预期的方法和数据类型)并不直观,我很庆幸找到了 David Mazières 关于协程的博客文章,并仔细研究了一番。
在语法上,我再次选择了一种简单的方法。生成器只是一个特殊函数,所以我只需在 JSValue 上再创建一个特殊的构造函数:
function* myGenerator() {
// ... body ...
}
然后编译成:
function* myGenerator() {
// ... body ...
}
C++ 将这个闭包识别为协程,因为它返回了一个 JSGeneratorAdapter。这是一个自定义类,实现了前面提到的 C++20 协程协议。
输入与输出
为了确保所有内容都能按预期工作,我编写并运行了一些自动化端到端测试。测试的基本思路是:包含一个 JavaScript 程序,该程序会被编译成 C++ 代码,然后再编译成真正的原生二进制文件。接着运行这个二进制文件,并将其输出与一个预定义的字符串进行比较。
然而,这个流程中还缺少一个环节:生成输出。幸运的是,POSIX 和 WASI 在读取和写入文件描述符方面共享了最基本的功能定义(即 read 和 write)。因此,为了简化操作,我直接将这些基本功能暴露给了 JavaScript:
static JSValue write_to_stdout(JSValue thisArg, std::vector
&args) {
JSValue data = args[0];
std::string str = data.coerce_to_string();
write(1 /* stdout */, str.c_str(), str.size());
return JSValue{true};
}
static JSValue read_from_stdin(JSValue thisArg, std::vector
&args) {
// ...
}
JSValue create_IO_global() {
JSValue global = JSValue::new_object({
{JSValue{"read_from_stdin"}, JSValue::new_function(read_from_stdin)},
{JSValue{"write_to_stdout"}, JSValue::new_function(write_to_stdout)}
});
return global;
}
create_IO_global() 函数是转译器为每个程序注入的所谓“前言”部分的内容之一,它使 IO 对象成为全局对象。如果程序未使用该对象,则 C++ 编译器的无效代码删除优化(Dead Code Elimination, DCE)会自动移除这部分代码。我利用这一基础架构编写了一整套测试程序,例如:
#[test]
fn for_loop() -> Result {
let output = compile_and_run(
r#"
let v = [];
for(let i = 0; i
v.push(i)
}
IO.write_to_stdout(v.length == 4 ? "y" : "n");
"#,
)?;
assert_eq!(output, "y");
Ok(())
}
(经过一些很少的手动清理后)这个测试会被编译成如下的 C++ 程序:
int prog() {
auto IO = create_IO_global();
auto v = JSValue::new_array({});
for (JSValue i = JSValue{0}; (i
v[JSValue{"push"}](i.boxed_value());
}
IO[JSValue{"write_to_stdout"}](
(v[JSValue{"length"}]) == JSValue{4}).coerce_to_bool()
? JSValue{"y"}
: JSValue{"n"}
);
return 0;
}
int main() {
try {
prog();
} catch (std::string e) {
printf("EXCEPTION: %s\n", e.c_str());
}
}
这就是整个流程。现在你已经了解了它的工作原理,那接下来我们可以回到这篇文章的开头陈述了。
最终,进入了死胡同
好吧,我认为这项技术已经走进了死胡同。我甚至都没有进行基准测试,因为我认为它根本无法与真正的 JavaScript 虚拟机(VM)竞争,更不用说与即时编译器(JIT)相比了。每个操作符都只是一大堆用于处理类型的 if-else 链——是的,每个操作符都是如此。
方法被保存在一个元组列表中,并且每次属性访问都需要遍历整个列表。这种动态查找的方式抵消了许多 C++ 编译器的强项:由于基于字符串的间接引用阻碍了静态分析,它无法进行内联或死代码消除(DCE)。如果我要用这种方式编写一个完全符合 ES2016 标准的转译器,我认为最终得到的东西不会比将 QuickJS 编译为 Wasm 更小或更快。
在我看来,一个更有潜力的方向可能是采用“近似 TypeScript”的策略,类似于 AssemblyScript:不是实现一个名为 JSValue 的超级类型,而是为每个类型实现其自己的 C++ 类。同时,我们可以通过一个简易的转译工具将 JavaScript 代码转换为 C++ 代码,在此过程中,利用 TypeScript 提供的类型注释来明确哪些 C++ 类被实例化和使用。这样一来,像类型检查、函数内联和代码优化等复杂任务就可以交由 C++ 编译器来完成了。而且,由于 C++20 已经具备闭包和生成器等特性,因此你几乎可以免费获得这些功能。当然,值得注意的是,截至当前,AssemblyScript 尚未实现对闭包和生成器的支持。
尽管这次尝试看似遇到了瓶颈,但我完全不后悔开发这个项目:这个项目非常有趣,也希望它能在某种程度上为大家提供帮助。
4000520066 欢迎批评指正
All Rights Reserved 新浪公司 版权所有