WASM
主线位置:请求主线在浏览器里的替身。
send()之后没有 tower 栈、没有连接池,也就没有连接主线:请求被翻译成一次fetch(),DNS、TCP、TLS、HTTP/2、重定向、cookie 都由浏览器做;响应主线读的是 JS 的Response对象。本页只读源码,不在浏览器里跑;编译用cargo check实测。
flowchart TB rb["RequestBuilder"] --> ex["execute_request"] ex --> mh["合并默认头"] mh --> init["RequestInit"] init --> ab["AbortGuard"] ab --> jf["js_fetch"] jf --> fut["JsFuture"] fut --> resp["Response"] ab -. 随响应走 .-> resp
同一组名字,另一套实现
lib.rs 用两个宏切开整个 crate:
macro_rules! if_wasm {
($($item:item)*) => {$(
#[cfg(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none")))]
$item
)*}
}
macro_rules! if_hyper {
($($item:item)*) => {$(
#[cfg(not(all(target_arch = "wasm32", any(target_os = "unknown", target_os = "none"))))]
$item
)*}
}
if_wasm! 那一段导出 wasm::{Body, Client, ClientBuilder, Request, RequestBuilder, Response} 和 multipart,名字和非 WASM 目标上的 async_impl 完全相同,实现一行不共用(三套实现怎么分配名字见源码地图)。两边共用的只有 error、into_url、response(ResponseBuilderExt)和 util,以及 http / url 的再导出。
src/wasm/ 共 2186 行。依赖也换了一套:Cargo.toml 里 tokio、hyper、hyper-util、tower、tower-http、rustls 都在 [target.'cfg(not(all(target_arch = "wasm32", ...)))'.dependencies] 下,两边共用的只有 [dependencies] 里的 http、url、bytes、base64 等少数几个,WASM 目标另加 js-sys、wasm-bindgen、wasm-bindgen-futures、web-sys(和 stream 用的 wasm-streams)。同一个 crate(exp-reqwest/bw-wasm,reqwest = "=0.13.5" 加 json、stream,默认 feature 不动)分别看依赖树:
aarch64-apple-darwin 103
aws-lc-rs v1.19.0;h2 v0.4.20;hyper v1.12.0;hyper-util v0.1.21;rustls v0.23.46;tokio v1.54.1;tower v0.5.3;tower-http v0.6.11;
wasm32-unknown-unknown 63
js-sys v0.3.106;wasm-bindgen v0.2.129;wasm-bindgen-futures v0.4.79;web-sys v0.3.106;
(cargo tree -e normal --prefix none 去重后的行数,含 bw-wasm 自己;第二行是从中挑出的几个。)默认 feature 里的 default-tls、http2、system-proxy 在 WASM 上照样能打开,只是它们指向的依赖都在非 WASM 的表里,什么也拉不进来。
wasm32-wasip2 曾经也被当成浏览器宏里的 any(target_os = "unknown", target_os = "none") 是 #3000「use wasm-bindgen ecosystem only for wasm32-unknown-* target」(11d835d,2026-04-17,首次发布在 v0.13.3)加的。之前两个宏只看 #[cfg(target_arch = "wasm32")],WASI 目标也会走 fetch 那一套、依赖 wasm-bindgen,可 WASI 运行时里没有浏览器的 fetch。改了以后 WASI 目标走 hyper 那一侧。走得通吗?对同一个 crate 跑 cargo check --target wasm32-wasip2:
error: Only features sync,macros,io-util,rt,time are supported on wasm.
error: could not compile `tokio` (lib) due to 1 previous error
非 WASM 那一侧需要 tokio 的网络支持,tokio 在 WASI 上不提供。所以 0.13.5 在 WASI 上是编不过,而不是编出一个运行时才失败的客户端。CHANGELOG 把这一条记成「(wasm) Only use wasm-bindgen on unknown-* targets」。
一次请求:翻译成 fetch
Client::execute_request 只做两件事:把 ClientBuilder 设的默认头填进请求的空位,然后调 fetch。fetch 逐项填一个 web_sys::RequestInit:
| reqwest | RequestInit / JS | 说明 |
|---|---|---|
method | method | |
| 头 | Headers,逐个 append | 值必须能 to_str()(可见 ASCII),否则是 builder 错误 |
默认 cors = true | 不设 mode | fetch_mode_no_cors() 才设 RequestMode::NoCors |
credentials,默认 None | 不设 | fetch_credentials_same_origin / include / omit |
cache | set_cache | fetch_cache_* 六个 |
referrer / referrer_policy | 同名 | 0.13.5 新加(#3049) |
| body | body | 空 body 不设;见下文 |
timeout | AbortController 的 signal | 见下一节 |
然后建 web_sys::Request,交给 js_fetch:
fn js_fetch(req: &web_sys::Request) -> Promise {
use wasm_bindgen::{JsCast, JsValue};
let global = js_sys::global();
if let Ok(true) = js_sys::Reflect::has(&global, &JsValue::from_str("ServiceWorkerGlobalScope"))
{
global
.unchecked_into::<web_sys::ServiceWorkerGlobalScope>()
.fetch_with_request(req)
} else {
// browser
fetch_with_request(req)
}
}
在 Service Worker 里调 ServiceWorkerGlobalScope.fetch,否则调全局的 fetch。返回的 Promise 经 wasm-bindgen-futures 的 JsFuture 变成 Rust 的 future。响应回来后,状态码和头逐个抄进一个 http::Response<web_sys::Response>,URL 取 JS Response.url,也就是浏览器跟完重定向之后的最终地址。
几件在非 WASM 上由 reqwest 做的事,这里一行代码都没有:
- 重定向:
RequestInit不设redirect,用浏览器默认的跟随。没有redirect::Policy,也没有 reqwest 的跨域去敏感头逻辑(非 WASM 的做法见重定向),这些都是浏览器的规则。 - 默认头:非 WASM 的
execute_request会补accept: */*(执行与 Pending),WASM 不补;wasm/client.rs里的单元测试特意断言了「no accept header」。 - 解压、cookie、代理、TLS:浏览器做,reqwest 不知道也管不了。
请求体只有完整的:wasm::Body 里是 Single::Bytes / Single::Text,或者 multipart 的 Form(转成浏览器的 FormData)。没有 wrap_stream,上传不能流式。响应体可以流式:stream feature 下 bytes_stream 用 wasm-streams 把 JS 的 ReadableStream 转成 Stream;text() 和 bytes() 直接调 JS 的 text()、arrayBuffer()。
AbortGuard:取消和超时
非 WASM 上,丢掉 future 就是取消请求。浏览器里 fetch 一旦发出就不归 Rust 管,丢掉 JsFuture 不会停下它,必须显式 abort。AbortGuard 包一个 AbortController,它的 signal 填进 RequestInit:
impl Drop for AbortGuard {
fn drop(&mut self) {
self.ctrl.abort();
if let Some((id, _)) = self.timeout.take() {
clear_timeout(id);
}
}
}
guard 在 fetch 里建,成功后移进 Response 的 _abort 字段,bytes_stream 还把它搬进流的闭包里(注释「Keep the abort guard alive as long as this stream is」)。所以:send() 的 future 被丢掉,请求被 abort;Response 或它的流被丢掉,还没读完的体被 abort;读完以后再 abort 是无害的空操作。
这两步是分两次做对的:#1755「Make wasm requests cancel when the future drops」(bb8fec4)加了 guard 并放进 Response,但 bytes_stream 拿走 body 时把 Response 拆了,guard 随之被丢掉,流刚开始读就被 abort;一个月后 #1782「wasm: fix premature abort for streaming bodies」(df2b3ba)把 guard 搬进流里。两个提交都首次发布在 v0.11.15。
超时也挂在这个 guard 上。AbortGuard::timeout 用 JS 的 setTimeout 定一个回调,到点 abort_with_reason("reqwest::errors::TimedOut"):
fn timeout(&mut self, timeout: Duration) {
let ctrl = self.ctrl.clone();
let abort =
Closure::once(move || ctrl.abort_with_reason(&"reqwest::errors::TimedOut".into()));
let timeout = set_timeout(
abort.as_ref().unchecked_ref::<js_sys::Function>(),
timeout.as_millis().try_into().expect("timeout"),
);
// ...
}
fetch 的 promise 被拒绝时,fetch 函数把错误转成字符串,等于 JsValue("reqwest::errors::TimedOut") 就换成 reqwest 自己的 TimedOut,is_timeout() 才认得出来(#2556,v0.12.13;超时本身是 #1760,同样首次发布在 v0.12.13)。几点和非 WASM 不同:
- 只有
RequestBuilder::timeout,每个请求单独设;ClientBuilder上没有timeout。 - guard 跟着
Response走,计时器在读体时还在跑,所以它是覆盖到读完体的总超时;没有connect_timeout、read_timeout。 setTimeout的毫秒数是i32,try_into().expect("timeout")意味着超过i32::MAX毫秒(约 24.8 天)的超时会 panic。阻塞客户端的同类问题(Duration::MAX)在 0.13.5 修了(阻塞客户端的冷知识),这里没有。本页没有在浏览器里跑,这一条是读源码得出的。
哪些 API 在 WASM 下不存在
ClientBuilder 只有 4 个方法。 wasm::ClientBuilder 只有 new、build、user_agent、default_headers;它的 Config 只有两个字段:
struct Config {
headers: HeaderMap,
error: Option<crate::Error>,
}
异步 ClientBuilder 在 tag 上有 90 个 pub fn(含各种 feature 和平台下的),阻塞版 87 个。超时、连接池、代理、TLS、DNS、HTTP 版本、重定向、重试、cookie、解压、connector_layer,全都不在。
整个模块不在。 if_hyper! 包住的 pub mod:blocking、cookie、dns、redirect、retry、tls,以及 Proxy、NoProxy、Certificate、Identity、Upgraded 的导出。
类型上少了的方法。 Error::is_connect、is_dns 只在非 WASM 上有(error.rs 里的 cfg);is_timeout 在 WASM 上不看 hyper 的错误。wasm::Response 没有 version()(源码里注释掉了,旁边写着「It might not be possible to detect this in JS?」),也没有 remote_addr、extensions、cookies、chunk、upgrade。Response 也不是 Send:lib.rs 的 _assert_impls 里 assert_send::<Response>() 包在 #[cfg(not(target_arch = "wasm32"))] 里,JS 对象不能跨线程。
反过来,有 12 个方法只在 WASM 上有。 RequestBuilder 的 fetch_mode_no_cors、fetch_credentials_* 三个、fetch_cache_* 六个、fetch_referrer、fetch_referrer_policy,在非 WASM 的 async_impl::RequestBuilder 上不存在。共用一份代码的库要给它们加 cfg。
wasm32-unknown-unknown 上 cargo check,编译器报几个错误?let _ = reqwest::Client::builder().timeout(Duration::from_secs(5));
let _ = reqwest::Client::builder().no_proxy();
let _ = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());
let _ = reqwest::Client::builder().pool_idle_timeout(None);
let _ = reqwest::Client::builder().connect_timeout(Duration::from_secs(1));
let _ = reqwest::Proxy::all("http://127.0.0.1:1");
平台:M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,cargo check --offline --target wasm32-unknown-unknown。
离线能不能编
能。本机装了 wasm32-unknown-unknown 目标,cargo 缓存里有 js-sys、web-sys、wasm-bindgen 系列,bw-wasm 的公共函数(建 Client、user_agent、get、timeout、error_for_status、text)离线 cargo check --target wasm32-unknown-unknown 一次通过,冷缓存 21.6 秒:
Checking web-sys v0.3.106
Checking wasm-streams v0.5.0
Checking reqwest v0.13.5
Checking bw-wasm v0.1.0 (.../bw-wasm)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 21.60s
同一个函数在 aarch64-apple-darwin 上也编得过:Client::builder().user_agent(..)、RequestBuilder::timeout、error_for_status、text 两边都有,这就是「同名」的用处。只做 check:按本卷的计划,WASM 一节只读源码,没有用 wasm-bindgen 生成 JS 胶水,也没有在浏览器里运行。
动脑筋答案
error[E0433]: cannot find `redirect` in `reqwest`
--> src/lib.rs:20:56
--> .../reqwest-0.13.5/src/lib.rs:380:13
error[E0433]: cannot find `Proxy` in `reqwest`
--> src/lib.rs:23:20
--> .../reqwest-0.13.5/src/lib.rs:364:27
error[E0599]: no method named `timeout` found for struct `ClientBuilder` in the current scope
--> src/lib.rs:18:38
error[E0599]: no method named `no_proxy` found for struct `ClientBuilder` in the current scope
--> src/lib.rs:19:38
error[E0599]: no method named `redirect` found for struct `ClientBuilder` in the current scope
--> src/lib.rs:20:38
error[E0599]: no method named `pool_idle_timeout` found for struct `ClientBuilder` in the current scope
--> src/lib.rs:21:38
error[E0599]: no method named `connect_timeout` found for struct `ClientBuilder` in the current scope
--> src/lib.rs:22:38
error: could not compile `bw-wasm` (lib) due to 7 previous errors
(只保留了 error 行和位置行。)7 个:6 行各错一次,第 3 行错两次,redirect 方法不存在,reqwest::redirect 模块也不存在。两个 E0433 还额外指向 reqwest 的 lib.rs 第 380 和 364 行,那是 if_hyper! 里 pub mod redirect 和 pub use self::proxy::{Proxy,NoProxy} 的位置,编译器提示它们被 cfg 关掉了。同样的 6 行在 aarch64-apple-darwin 上编译通过。
小结
if_wasm!/if_hyper!按wasm32加unknown/none切换实现:同名的Client、Request、Response、Body,实现不共用;WASI 目标从 v0.13.3 起走 hyper 一侧,在 tokio 那里编不过。- 一次请求就是一次
fetch():方法、头、mode、credentials、cache、referrer、body 填进RequestInit;重定向、cookie、TLS、解压、连接复用全交给浏览器,reqwest 不补accept。 AbortGuard跟着Response和bytes_stream走,丢掉就 abort;RequestBuilder::timeout用setTimeout加abort_with_reason实现,覆盖到读完体,超过i32::MAX毫秒会 panic。ClientBuilder只有new、build、user_agent、default_headers;blocking、cookie、dns、redirect、retry、tls、Proxy不存在;Response没有version()、不是Send;反过来 12 个fetch_*方法只在 WASM 上有。- 依赖从 103 个 crate 降到 63 个,没有 tokio、hyper、rustls;
wasm32-unknown-unknown离线cargo check能过。