12k
All articles

JSPI 详解:JavaScript 与 Wasm 之间更优雅的桥梁

JSPI 连接 JavaScript 和 WebAssembly,让同步 Wasm 调用 fetch 等基于 Promise 的 API,并介绍 Suspending、promising 和浏览器支持。

OpenReplay Team
OpenReplay Team
JSPI 详解:JavaScript 与 Wasm 之间更优雅的桥梁

JavaScript Promise Integration(JSPI)允许 WebAssembly 模块像调用同步函数一样调用返回 Promise 的 JavaScript 导入:当导入返回 Promise 时,模块挂起执行;当 Promise 解析后,模块以解析值恢复执行,无需手动管理回调。这一核心能力填补了一个长期存在的空白——由 C、C++ 或 Rust 编译而来的同步 Wasm 代码,此前无法在不借助繁重工具链的情况下 await 诸如 fetch 或 IndexedDB 这类异步浏览器 API。本文将介绍 JSPI 所解决的问题、当前的双函数 API、一个可运行的 fetch 示例,以及截至 2026 年的发布现状。需要特别提醒的是:目前大多数 JSPI 教程仍在介绍已被移除的 Suspender 对象 API——以下内容均基于当前的最新接口。

核心要点

  • JSPI 的公开 API 仅由两部分组成:new WebAssembly.Suspending(fn) 用于标记一个返回 Promise 的导入函数;WebAssembly.promising(exportFn) 用于将导出的 Wasm 函数包装为返回 Promise 的函数。
  • 特性检测应使用 'Suspending' in WebAssembly,切勿使用 'Suspender' in WebAssembly——后者检测的是 2024 年前已被移除的旧版 API。
  • 截至 2026 年中,JSPI 已是第 4 阶段(实际上已完成标准化)的提案,在 Chrome 137+ 中正式发布,在 Safari 27 beta 中可用,在 Firefox 中仅限 Nightly 版本通过配置项开启,并计划在 Firefox 153 中默认启用。
  • 若导入的 Promise 被拒绝(reject),JSPI 会向挂起的计算抛出异常,而非向 Wasm 返回错误值。
  • 与 Binaryen 的 Asyncify 不同,JSPI 使用引擎原生的栈切换机制,因此二进制文件保持直线型同步代码,无需任何插桩膨胀。

痛点所在:同步 Wasm 遭遇异步 Web

这一矛盾的根源在于架构层面的差异。由 C、C++ 或 Rust 编译而来的 WebAssembly 假定调用是阻塞式的——一个函数调用另一个函数,等待返回值,然后继续执行。而 Web 平台恰恰相反:fetch、IndexedDB 以及大多数现代浏览器 API 均返回 Promise,并由事件循环驱动异步解析。当 Wasm 调用一个返回 Promise 的 JavaScript 函数时,模块本身没有任何原生机制来暂停执行、等待 Promise 解析,再从断点处恢复。

在 JSPI 出现之前,标准的解决方案是 Binaryen 的 Asyncify——一种全程序变换,它重写整个 Wasm 二进制文件,使其能够将自身调用栈展开(unwind)到线性内存中,并在之后重新卷绕(rewind)。这种方案确实可行,但代价不菲:变换会显著膨胀二进制体积,并为所有被插桩的函数增加逐次调用的额外开销。对于一个偶尔需要在计算过程中 fetch 配置或读取 IndexedDB 的高吞吐量计算模块而言,为整个模块承担这一代价实属得不偿失。

JavaScript Promise Integration 的工作原理

JavaScript Promise Integration 通过将同步的 Wasm 调用映射为异步调用,在同步 WebAssembly 与异步 Web API 之间架起桥梁:当调用携带 Promise 的导入函数时,模块挂起执行;当 Promise 完成(settle)后,模块恢复执行。它允许 WebAssembly 应用调用所谓的”携带 Promise 的导入函数”并获取 Promise 的值,而无需显式管理通常与 Promise 相关联的异步回调。

重要的是,这并非对语言本身的修改。该提案既未改变 JavaScript 语言,也未改变 WebAssembly 语言,没有新增任何 WebAssembly 指令或类型。从语义上讲,所有变更都发生在 WebAssembly 与 JavaScript 的边界处。这种边界定位对 API 设计以及挂起作用域的界定方式至关重要。

双组件 API 与可运行的 fetch 示例

JSPI 的全部公开接口仅由两个元素组成。JSPI API 包含两个元素WebAssembly.Suspending 构造函数和 WebAssembly.promising 函数。new WebAssembly.Suspending(fn) 用于标记一个返回 Promise 的导入函数;WebAssembly.promising 函数用于将导出的 WebAssembly 函数包装为返回 Promise 的函数。注意大小写区别——Suspending 是构造函数(首字母大写),promising 是普通函数(首字母小写)。

以下是基于规范示例改编的标准用法:一个用 Suspending 包装的基于 fetch 的导入函数、一个用 promising 包装的导出函数,以及在 JavaScript 侧对返回 Promise 的 await 调用。

// 一个返回 Promise(解析为数字)的异步导入函数
const computeDelta = () =>
  fetch('https://example.com/data.txt')
    .then(res => res.text())
    .then(txt => parseFloat(txt));

const importObject = {
  js: {
    // 将返回 Promise 的导入函数标记为 suspending
    compute_delta: new WebAssembly.Suspending(computeDelta),
  },
};

const { instance } = await WebAssembly.instantiateStreaming(
  fetch('module.wasm'),
  importObject,
);

// 包装导出函数,使其调用时返回 Promise
const updateState = WebAssembly.promising(instance.exports.update_state);

const result = await updateState(); // 在调用 compute_delta 时于 Wasm 内部挂起,以解析值恢复执行

update_state 内部,Wasm 代码以普通的同步调用签名调用 compute_delta。当该导入函数返回 Promise 时,模块挂起;当 Promise 解析后,解析值成为该导入函数的返回值,执行随即继续。

值得了解的行为细节

以下三点细节,使 JSPI 的实际行为有别于直觉上的简单模型。

挂起范围由 JS/Wasm 边界界定。 Suspending 导入与 promising 导出构成一对——最内层对包装导出函数的调用决定了挂起的切割点。只有 WebAssembly 计算可以通过 JSPI 挂起;这一约束通过要求在调用 promising 函数与调用 Suspending 包装的导入函数之间仅有 WebAssembly 帧处于活动状态来强制执行。

只有在实际返回 Promise 时才会挂起。 并非每次从 suspending 导入调用 JavaScript 函数时都会挂起,只有当 JavaScript 函数实际返回 Promise 时才会挂起。若返回的是普通值,则直接透传,不会触发事件循环。

被拒绝的 Promise 会向 Wasm 抛出异常。 若 Promise 被拒绝,JSPI 不会以错误值恢复 WebAssembly 模块的执行,而是将异常传播到挂起的计算中。实际上,拒绝处理通常在 JavaScript 侧完成,因为 Rust 等语言往往无法直接处理该抛出的异常——wasm-bindgen 项目正在讨论添加一种显式的错误指示类型,目前尚无定论。

浏览器与工具链现状(2026 年)

JSPI 已进入 W3C WebAssembly 流程的第 4 阶段——它已是 W3C WebAssembly WG 的第 4 阶段提案,意味着规范已经过 W3C Wasm CG 的投票表决,实际上已完成标准化。该规范于 2025 年 4 月由 W3C WebAssembly CG 完成标准化

环境状态(2026 年中)
Chrome / EdgeChrome 137(2025 年 5 月)起在稳定版中正式发布
SafariSafari 27 beta 中可用
Firefox仅限 Nightly 版本通过配置项开启;计划在 Firefox 153 中默认启用
Node.js通过 --experimental-wasm-jspi 标志启用

关于 Firefox 的状态,请以 Mozilla 官方信息为准,而非网络上流传的”Firefox 139”版本号:根据发布意向声明(2026 年 6 月 10 日),该特性已在配置项后开发并发布,自 Fx152 起仅在 Nightly 版本中启用。Mozilla 计划从 Firefox 153 起在所有平台上默认启用 WebAssembly JS-Promise-Integration(JSPI)。截至本文撰写时,caniuse 仍将 Firefox 稳定版标记为尚未默认启用,请在依赖该特性前自行确认。

在工具链方面,大多数 C/C++ 项目无需修改源代码。如果你是 Emscripten 用户,使用新 API 通常不需要对代码做任何改动。你只需使用至少 3.1.61 版本的 Emscripten。特性检测方式如下:

if ('Suspending' in WebAssembly) {
  // JSPI 可用——配置 Suspending / promising
} else {
  // 回退到使用 Asyncify 构建的模块
}

请检测 WebAssembly.Suspending,而非 Suspender:旧版 API 至少会持续运行至 2024 年 10 月 29 日(Chrome M128),此后计划移除。需要注意的是,Emscripten 本身自 3.1.61 版本起也不再支持旧版 API。此前存在一个基于 Suspender 对象的旧版 API,现已被移除——如果某篇教程中出现 WebAssembly.Suspender 或带有 returnPromiseOnSuspendnew WebAssembly.Function(...),则该教程已经过时。

JSPI 与 Asyncify 的简要对比

两者的根本区别在于挂起逻辑的位置。Asyncify 将其置于你的二进制文件中;JSPI 将其置于引擎中。由于挂起和恢复 WebAssembly 模块所使用的机制本质上是常数时间的,我们预计使用 JSPI 的开销不会很高——尤其是与其他基于变换的方案相比。这意味着更小的输出体积和更低的逐次调用开销,以引擎原生栈切换取代全程序重写。当前实现为每个挂起的计算分配固定大小的栈;可增长(分段)栈已列入路线图,以支持大量协程并发,但尚未发布。

如果你将代码编译为 Wasm,并遭遇了同步代码需要调用异步 Web API 的瓶颈,JSPI 是当前的最优解:用 WebAssembly.Suspending 包装导入函数,用 WebAssembly.promising 包装导出函数,以 'Suspending' in WebAssembly 进行特性检测,并仅将 Asyncify 保留为尚未支持 JSPI 的引擎的回退方案。

常见问题

JSPI 与 Asyncify 有何区别?

Asyncify 是一种全程序 Binaryen 变换,它重写整个 Wasm 二进制文件,使其能够将自身调用栈展开并重新卷绕到线性内存中,从而导致二进制体积膨胀,并为所有被插桩的函数增加逐次调用的额外开销。JSPI 则将该逻辑移入引擎,使用原生栈切换,因此模块保持直线型同步代码,无需任何插桩。V8 将 JSPI 的挂起和恢复机制描述为本质上是常数时间的,而 Asyncify 则对整个模块造成性能损耗。

使用 JSPI 是否需要修改 Emscripten 的 C 或 C++ 源代码?

不需要。Emscripten 自 3.1.61 版本起会自动生成当前的 JSPI API,因此大多数 C 和 C++ 项目无需修改源代码,即可从旧版 Suspender 对象 API 迁移到新的 Suspending 和 promising 接口。你只需使用 Emscripten 3.1.61 或更高版本进行构建;旧版 API 也在同一版本中从 Emscripten 中移除,因此使用旧版工具链仍会生成已废弃的接口。

若导入的 Promise 被拒绝会发生什么?

被拒绝的 Promise 不会向 Wasm 返回错误值;JSPI 会将异常传播到挂起的计算中。实际上,拒绝处理通常在 JavaScript 侧完成,因为 Rust 等语言往往无法直接处理该抛出的异常。Wasm 导入签名可能声明的是普通整数,尽管它实际上代表一个 Promise,而 wasm-bindgen 目前正在讨论添加一种显式的错误指示类型,尚无定论。

调用 JSPI 导入函数是否总会挂起模块?

不会。JSPI 只有在 JavaScript 导入函数实际返回 Promise 时才会挂起。若导入函数返回的是普通同步值,该结果会直接透传给 Wasm 调用方,不会发生挂起,也不会触发事件循环。这一行为在 JavaScript 与 WebAssembly 的边界处定义,因此同一个被包装的导入函数在运行时可能表现为同步或异步,取决于底层函数实际返回的内容。

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.