“wasm 中的 JS 对象” 的 Polyfill
wasm-bindgen
的主要目标之一是允许在 wasm 中使用和传递 JS 对象,但现在不允许这样做!虽然确实如此,但这正是 polyfill 的用武之地。
这里的问题是如何将 JS 对象强行塞入 u32
以供 Wasm 使用。当前这种方法采取的策略是在生成的 foo.js
文件中维护一个模块局部变量:一个 heap
。
“堆栈”上的临时 JS 对象
foo.js
中的 heap
的第一个槽被认为是堆栈。这个堆栈就像典型的程序执行堆栈一样向下增长。JS 对象被压入堆栈底部,它们在堆栈中的索引是传递给 wasm 的标识符。维护一个堆栈指针来确定下一个项目被压入的位置。
然后 JS 对象也只从堆栈底部移除。移除只是存储 null 然后递增计数器。由于这种方案的“堆栈式”性质,它只在 Wasm 不保留 JS 对象时起作用(又名它只在 Rust 中获得一个“引用”)。
让我们来看一个例子。
# #![allow(unused_variables)] #fn main() { // foo.rs #[wasm_bindgen] pub fn foo(a: &JsValue) { // ... } #}
这里我们正在使用 wasm-bindgen
库本身的特殊 JsValue
类型。我们导出的函数 foo
接受对对象的引用。这尤其意味着它无法在本次函数调用生命周期之后保留该对象。
现在我们实际想要生成的是一个看起来像(在 TypeScript 说法中)的 JS 模块
// foo.d.ts
export function foo(a: any);
而我们实际生成的是这样的
// foo.js
import * as wasm from './foo_bg';
const heap = new Array(32);
heap.push(undefined, null, true, false);
let stack_pointer = 32;
function addBorrowedObject(obj) {
stack_pointer -= 1;
heap[stack_pointer] = obj;
return stack_pointer;
}
export function foo(arg0) {
const idx0 = addBorrowedObject(arg0);
try {
wasm.foo(idx0);
} finally {
heap[stack_pointer++] = undefined;
}
}
在这里,我们可以看到一些值得注意的动作点
- Wasm 文件已重命名为
foo_bg.wasm
,我们可以看到这里生成的 JS 模块是如何从 Wasm 文件导入的。 - 接下来,我们可以看到我们的
heap
模块变量,它用于存储所有可从 wasm 引用的 JS 值。 - 我们导出的函数
foo
接受一个任意参数arg0
,该参数使用addBorrowedObject
对象函数转换为索引。然后将索引传递给 Wasm,以便 Wasm 可以使用它。 - 最后,我们有一个
finally
,它会释放堆栈槽,因为它不再使用,弹出在函数开始时压入的值。
深入了解 Rust 方面的情况也很有帮助,看看那里发生了什么!让我们看一下 #[wasm_bindgen]
在 Rust 中生成的代码
# #![allow(unused_variables)] #fn main() { // what the user wrote pub fn foo(a: &JsValue) { // ... } #[export_name = "foo"] pub extern "C" fn __wasm_bindgen_generated_foo(arg0: u32) { let arg0 = unsafe { ManuallyDrop::new(JsValue::__from_idx(arg0)) }; let arg0 = &*arg0; foo(arg0); } #}
和 JS 一样,这里值得注意的点是
- 原始函数
foo
在输出中未修改 - 这里生成了一个函数(具有唯一的名称),它是实际从 Wasm 模块导出的函数
- 我们生成的函数接受一个整数参数(我们的索引),然后将其包装在
JsValue
中。这里有一些技巧,暂时不值得深入探讨,但我们稍后会看到幕后发生的事情。
长期存在的 JS 对象
当 JS 对象仅在 Rust 中临时使用时,例如仅在一次函数调用期间,上述策略非常有用。但是,有时对象的生命周期可能是动态的,或者需要在 Rust 的堆上存储。为了应对这种情况,对 JS 对象有第二部分管理,自然地对应于 JS heap
数组的另一侧。
传递给 Wasm 的不是引用的 JS 对象假定在 Wasm 模块内部具有动态生命周期。因此,严格的堆栈推/弹将不起作用,我们需要为 JS 对象提供更永久的存储。为了应对这种情况,我们构建了自己的某种“slab 分配器”。
一图胜千言,所以让我们用一个例子来说明发生了什么。
# #![allow(unused_variables)] #fn main() { // foo.rs #[wasm_bindgen] pub fn foo(a: JsValue) { // ... } #}
请注意,&
在我们之前拥有的 JsValue
前面缺失了,在 Rust 说法中,这意味着它正在获取 JS 值的所有权。导出的 ES 模块接口与之前相同,但所有权机制略有不同。让我们看看生成的 JS 的 slab 如何工作
import * as wasm from './foo_bg'; // imports from Wasm file
const heap = new Array(32);
heap.push(undefined, null, true, false);
let heap_next = 36;
function addHeapObject(obj) {
if (heap_next === heap.length)
heap.push(heap.length + 1);
const idx = heap_next;
heap_next = heap[idx];
heap[idx] = obj;
return idx;
}
export function foo(arg0) {
const idx0 = addHeapObject(arg0);
wasm.foo(idx0);
}
export function __wbindgen_object_drop_ref(idx) {
heap[idx ] = heap_next;
heap_next = idx;
}
与之前不同,我们现在在 foo
的参数上调用 addHeapObject
,而不是 addBorrowedObject
。此函数将使用 heap
和 heap_next
作为 slab 分配器来获取槽以存储对象,一旦找到,就在那里放置一个结构。请注意,这在数组的右半部分进行,这与位于左半部分的堆栈不同。这种规则大致反映了普通程序中的堆栈/堆。
此生成模块的另一个奇怪之处是 __wbindgen_object_drop_ref
函数。这是一个实际导入到 wasm 而不是在此模块中使用的函数!此函数用于表示 Rust 中 JsValue
的生命周期结束,换句话说,当它超出范围时。否则,此函数在很大程度上只是一个通用的“slab free”实现。
最后,让我们再来看看生成的 Rust 代码
# #![allow(unused_variables)] #fn main() { // what the user wrote pub fn foo(a: JsValue) { // ... } #[export_name = "foo"] pub extern "C" fn __wasm_bindgen_generated_foo(arg0: u32) { let arg0 = unsafe { JsValue::__from_idx(arg0) }; foo(arg0); } #}
啊,看起来熟悉多了!这里没什么特别有趣的,所以我们继续看...
JsValue
的结构
目前,JsValue
结构在 Rust 中实际上非常简单,它是:
# #![allow(unused_variables)] #fn main() { pub struct JsValue { idx: u32, } // "private" constructors impl Drop for JsValue { fn drop(&mut self) { unsafe { __wbindgen_object_drop_ref(self.idx); } } } #}
或者换句话说,它是一个围绕 u32
的 newtype 包装器,这个 u32
是我们从 wasm 接收到的索引。这里的析构函数会调用 __wbindgen_object_drop_ref
函数,以放弃我们对 JS 对象的引用计数,从而释放我们在上面看到的 slab
中的槽位。
如果你还记得,当我们上面获取 &JsValue
时,我们生成了一个围绕局部绑定的 ManuallyDrop
包装器,那是因为我们想避免当对象来自堆栈时调用这个析构函数。
在现实中处理 heap
上面的解释与今天发生的情况非常接近,但实际上存在一些差异,尤其是在处理像 undefined
、null
等常量值时。请务必查看实际生成的 JS 和生成代码,以了解完整详细信息!