一次请求的渲染
主线位置:首屏主线的服务端部分,从 axum 收到
GET /到最后一块 HTML 写进响应体。一次页面请求已经按顺序走过这一段并标注了 curl 拿到的每一块 HTML,这一页不再重复那些标注,而是往下挖几处它只点到为止的地方:路由表里到底有什么、render_to的每一步、本地线程池的大小和阻塞的代价、状态码和响应头的截止时刻、出错时渲染器的去向。HTML 本身怎么写见 dioxus-ssr,水合数据见水合。
flowchart TB launch["dioxus::launch"] --> sserver[serve_server] serve["dioxus::serve 闭包"] --> srouter[serve_router] sserver --> srouter routerfn["server::router app"] --> sda[serve_dioxus_application] sserver --> sda sda --> sfn["server function 路由"] sda --> assets["public 静态文件"] sda --> fb["fallback get render_handler"] fb --> rt["render_to"] rt --> pool["spawn_pinned 本地线程"] pool --> vd["新 VirtualDom"] vd --> status["根错误边界 定状态码"] status --> chunks["分块写 HTML"]
本页的实验都用同一个应用:一个 Routable 枚举挂着几个页面,各测一件事;三个 #[get] server function。用 dx build --platform web --fullstack --release(dx 0.7.9,库 0.7.10)构建,IP=127.0.0.1 PORT=18790 ./server 启动。平台是 M2 Max(12 核)、macOS 27、rustc 1.99.0。各页面的代码在用到它的小节里给出。
三个入口,一张路由表
服务端有三种起法,最后都落到同一个方法上:
| 写法 | 走到哪 | 用的 ServeConfig |
|---|---|---|
dioxus::launch(app) / LaunchBuilder::launch | launch_cfg 建一个 tokio 多线程运行时,block_on serve_server | with_cfg 传进来的,没有就 ServeConfig::new() |
| `dioxus::serve( | async { Ok(router) })` | |
dioxus::server::router(app) | router 只返回一个 axum::Router,通常放在 serve 的闭包里,再往上加自己的路由和中间件 | 固定 ServeConfig::new() |
serve_server 和 router 里都是这一行,外面再套一层 apply_base_path(配了 base path 时把整张表 nest 到 /{base}/ 下):
Router::new().serve_dioxus_application(cfg.clone(), original_root),
serve_router 先初始化日志、绑端口,然后分两路:release 构建直接 axum::serve(listener, router);debug 构建自己写一个 accept 循环,同时 select! 等 dx 发来的热补丁消息,收到就重建整张路由表,并通知已有连接关闭。热补丁本身不在本卷范围内,这里只需要知道 release 下就是普通的 axum::serve。
self.register_server_functions()
.serve_static_assets()
.fallback(get(FullstackState::render_handler))
.with_state(FullstackState::new(cfg, app))
server function 路由
register_server_functions 遍历 inventory 收集到的每个 ServerFunction,用 "{method} {path}" 去重,再 route(path, method_router)。实验应用启动时的日志就是这三行:
2026-10-28T11:54:35.354429Z INFO dioxus_server::server: Registering: GET /api/sleep
2026-10-28T11:54:35.354986Z INFO dioxus_server::server: Registering: GET /api/word
2026-10-28T11:54:35.354999Z INFO dioxus_server::server: Registering: GET /api/num
宏怎么把函数登记进 inventory、handler 里怎么解码参数,见 server function。
静态文件
serve_static_assets 先找 public 目录(public_path:环境变量 DIOXUS_PUBLIC_PATH,否则是可执行文件旁边的 public/),然后 serve_dir_cached 在启动时把目录读一遍:
index.html跳过,它由 fallback 渲染出来。- 文件:每个文件一条
nest_service路由,ServeFile::new(&path).precompressed_br()(有.br就发压缩版)。文件名形如名字-dxh<十六进制>.扩展名的,再包一层cache_response_forever,加cache-control: public, max-age=31536000, immutable。判断就是file_name_looks_immutable:-dxh之后到第一个.为止全是十六进制数字。 - 子目录:release 递归下去,同样逐个文件注册;debug 直接
nest_service(&route, ServeDir::new(&path)),请求到来时才查文件系统。
release 构建里 public/ 是 index.html 加 assets/ 下两个带 hash 的文件,curl 其中的 JS:
HTTP/1.1 200 OK
content-type: text/javascript
accept-ranges: bytes
last-modified: Wed, 28 Oct 2026 11:54:28 GMT
content-length: 67551
cache-control: public, max-age=31536000, immutable
debug 构建的文件在 public/wasm/ 下,名字不带 hash(exp-dioxus-fs1-fs.js),走 ServeDir,响应里没有 cache-control:
HTTP/1.1 200 OK
content-type: text/javascript
accept-ranges: bytes
last-modified: Wed, 28 Oct 2026 11:58:17 GMT
content-length: 121870
(两段都去掉了 date 行。)
issue #3278「Web app resources are not cached」抱怨:第二次打开页面时,wasm 和 JS 一个字节都没重新下载,浏览器却对每个文件发 if-modified-since、等一个 304 Not Modified,光这一来一回就花了 136 毫秒。PR #3928(2025-06-20 合并,首次随 v0.7.0-alpha.2 发布)关掉了这个 issue。PR 说明里的方案是让 CLI 写一个 JSON 文件,告诉服务端哪些资源带了 hash;合并时的提交列表里有一条「look for hash in asset name instead of building a manifest」,最后落地的就是上面那个只看文件名的 file_name_looks_immutable。debug 下子目录改用 ServeDir 是之后的 PR #4983(首次随 v0.7.3 发布),原因是热补丁会往 public/wasm/ 里写新文件,启动时注册好的路由表里没有它们,浏览器请求补丁拿到 404。
fallback
剩下的所有路径都进 get(FullstackState::render_handler)。外面包着 get,所以别的方法打到页面路径上是 405;路径就算看起来像文件,只要启动时没注册,也会被当成一次页面渲染:
$ curl --noproxy '*' -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:18790/
405
$ curl --noproxy '*' -sI http://127.0.0.1:18790/index.html
HTTP/1.1 404 Not Found
content-length: 779
/index.html 的 404 不是静态文件服务给的,而是 router 解析不了 /index.html 这个路由(下面「状态码」一节)。axum 那一侧这张表怎么合并、fallback 怎么挂,见 axum 卷的 dioxus 生成的 Router。
FullstackState:一个线程池,一个渲染器池
with_state 的 FullstackState::new 造两样东西,之后每个请求共享:
let rt = LocalPoolHandle::new(
std::thread::available_parallelism()
.map(usize::from)
.unwrap_or(1),
);
Self {
renderers: Arc::new(SsrRendererPool::new(4, config.incremental.clone())),
build_virtual_dom: Arc::new(move || VirtualDom::new_with_props(root.clone(), ())),
config,
rt,
}
rt:tokio-util 的LocalPoolHandle,线程数是available_parallelism,这台机器上是 12。它和 axum 跑在上面的那个 tokio 多线程运行时是两套线程。renderers:SsrRendererPool,一个RwLock<Vec<Renderer>>,预先放 4 个pre_render = true的渲染器,外加可选的增量缓存。build_virtual_dom:每次调用都VirtualDom::new_with_props,所以每个请求一棵全新的树。
每个 FullstackState 都有自己的线程池。apply_base_path 为 base path 的根路径又 FullstackState::new 了一次,配了 base path 的应用就有两个池;dioxus::server::router 每调一次也是一个新池。
render_handler 只留下请求的 Parts,请求体直接丢掉;工厂闭包在 build_virtual_dom 之外,再把 ServeConfig 里的每个 context_providers 塞进根 context。
render_to 逐步
SsrRendererPool::render_to 运行在 axum 的 handler 里(多线程运行时),它自己不渲染,只准备好通道、把渲染交出去、等一个结果:
- 取
parts.uri.path_and_query()作路由字符串,取不到就是 400。 - 建两条通道:容量 1000 的
mpsc,渲染那边往里写 HTML 块,handler 把接收端当响应体;一个oneshot,渲染那边决定好状态码和响应头后发一次。 check_cached_route:配了增量缓存且命中,把整页字符串塞进通道,直接返回 200,不碰线程池(见增量渲染与配置)。- 从渲染器池
pop一个;池空了就pre_renderer现造一个。 - 把剩下的整个渲染过程包成闭包
create_render_future,rt.spawn_pinned(create_render_future)。 initial_result_rx.await:等那个 oneshot。拿到Ok((status, headers))就返回,响应体是一个包着接收端的ReceiverWithDrop;拿到Err就是错误响应。
ReceiverWithDrop 的 Drop 会 abort 那个渲染任务:客户端断开、axum 丢掉响应体时,本地线程上的渲染也就停了。
闭包在本地线程上做的事,按顺序:
| 步骤 | 做什么 | 本卷哪里讲 |
|---|---|---|
| 建树 | virtual_dom_factory(),根上放 ServerDocument、FullstackContext::new(parts)、MemoryHistory、可水合的错误边界工厂,rebuild_in_place | 一次页面请求 |
| 等 | Disabled:wait_for_suspense().await 等完所有挂起;OutOfOrder:循环 wait_for_suspense_work / render_suspense_immediate,直到 router 提交了首块或没有挂起任务 | Suspense 与错误边界、流式渲染 |
| 定状态码 | 查根错误边界,有错就把错误发进 oneshot 并 return;没错就发 current_http_status() 和 take_response_headers() | 本页下一节 |
| 写头 | render_head 写 index.html 的 <head> 和 ServerDocument 收集的元素,StreamingRenderer::new 把它作为第一块送出 | document 与 eval |
| 写主体 | renderer.pre_render = true,装上流式回调,renderer.render(&virtual_dom),接着 render_after_main 写水合数据和模板剩下的部分,作为第二块送出 | dioxus-ssr、水合 |
| 流式补块 | while suspended_tasks_remaining():每个完成的边界发一段替换块 | 流式渲染 |
| 收尾 | render_after_body 写 </body> 之后的模板;有增量缓存就把整页(不带流式的部分)重新渲染一遍存起来;送出最后一块 | 增量渲染与配置 |
| 还渲染器 | reset_render_components(),push 回池子 | 本页最后一节 |
本地线程池:为什么、有几个、阻塞挡住谁
VirtualDom 里到处是 Rc 和 RefCell(Runtime 本身就是 Rc 共享的,见 VirtualDom 与 Runtime),它不是 Send,渲染它的 future 也不是。tokio 的多线程调度器会在线程之间搬任务,tokio::spawn 要求 Send;spawn_pinned 只要求「造 future 的闭包」是 Send,闭包被送到池里某个线程上调用,造出的 future 从此只在那个线程上 poll。create_render_future 捕获的东西(Parts、ServeConfig、渲染器、通道的发送端、工厂闭包)都是 Send,VirtualDom 是在闭包里面才新建的。
池里每个线程跑一个只属于自己的单线程调度器。具体是哪一种取决于你的 Cargo.lock 里的 tokio-util 版本(dioxus 只要求 0.7.15 以上):0.7.15 到 0.7.19 是 new_current_thread() 运行时上套一个 LocalSet,0.7.20 改成了 tokio 的 LocalRuntime。本页实验的 lock 里是 0.7.20,本站的 lock 里是 0.7.18。两者都是「一个线程、一个只跑本线程任务的调度器」,下面的结论不受影响(LocalSet 和 LocalRuntime 本身见 tokio 卷的 JoinSet、LocalSet、task_local)。
选线程的规则是 find_and_incr_least_burdened_worker:每个线程有一个原子计数器,记着它身上还没结束的任务数,挑最小的那个,compare_exchange 加一;任务结束时减一。
这就决定了阻塞的代价。实验里两个页面,一个在渲染时同步睡 500 毫秒,一个在 use_server_future 里异步等 500 毫秒(sleep_ms 是一个 #[get] server function,函数体里 tokio::time::sleep),都把自己所在线程的 id 写进页面:
#[cfg(feature = "server")]
fn tid() -> String {
format!("{:?}", std::thread::current().id())
}
#[component]
fn Block() -> Element {
// blocking the local thread while rendering
let t = tid();
#[cfg(feature = "server")]
{
dioxus::logger::tracing::info!("block start on {t}");
std::thread::sleep(std::time::Duration::from_millis(500));
dioxus::logger::tracing::info!("block end on {t}");
}
rsx! { p { "blocked on {t}" } }
}
#[component]
fn Wait() -> Element {
// awaiting instead of blocking
let t = tid();
let v = use_server_future(|| sleep_ms(500))?;
rsx! { p { "waited {v():?} on {t}" } }
}
同时发 16 个 curl,记下每个的 time_total(排好序),再统计页面里的线程 id:
== /block x16
0.500887 0.501934 0.502850 0.504470 0.505052 0.505404 0.505703 0.505716 0.505824 0.505831 0.506109 0.506506 0.995513 0.998171 0.999937 1.001352
== /wait x16
0.502816 0.502847 0.502854 0.503181 0.503245 0.503246 0.503463 0.503597 0.503646 0.503935 0.504181 0.504255 0.504327 0.504506 0.505535 0.507546
2 blocked on ThreadId(17)
2 blocked on ThreadId(16)
2 blocked on ThreadId(15)
2 blocked on ThreadId(14)
1 blocked on ThreadId(25)
1 blocked on ThreadId(24)
1 blocked on ThreadId(23)
1 blocked on ThreadId(22)
1 blocked on ThreadId(21)
1 blocked on ThreadId(20)
1 blocked on ThreadId(19)
1 blocked on ThreadId(18)
/wait 那一组的线程分布一模一样(14 到 17 各两次,18 到 25 各一次),时间却全是 0.5 秒。读法:
- 一共 12 个线程(
ThreadId(14)到ThreadId(25)),等于available_parallelism。 - 前 12 个请求各分到一个空闲线程;第 13 到 16 个请求到来时每个线程的计数都是 1,只能排到已经有任务的线程上。
- 同步阻塞时,排在后面的那 4 个要等前一个睡完才开始,所以是 1 秒。异步等待时,线程在
.await处让出,同一线程上的两个渲染交替进行,都是 0.5 秒。
阻塞挡住的不止是同一页面。再做一次:先发 12 个 /block 占满所有线程,100 毫秒后依次请求首页、一个静态文件、一个 server function,最后等全部结束再单独请求一次首页:
home during 12 blocks: 0.390720
asset during 12 blocks: 0.000768
server fn during 12 blocks: 0.000602
home alone: 0.000645
首页本身什么也不做,平时 0.6 毫秒,这次等了 0.39 秒:它被分到了一个正在睡的线程上,等那次 /block 睡完(500 毫秒减去先睡掉的 100 毫秒)才开始渲染。静态文件不受影响,它走的是 axum 的多线程运行时,不进本地线程池。server function 这里看起来也很快,但那只是因为请求是依次发的:首页那 0.39 秒已经把 /block 等完了。server function 的 handler 其实也进同一个池,make_handler 里是 state.rt.spawn_pinned(...),注释写着「Allow !Send futures by running in the render handlers pinned local pool」;把首页、静态文件和 server function 同时发出去,server function 也要等 0.39 秒,只有静态文件是 1 毫秒以内(实验见设计取舍)。所以服务端渲染里做同步 IO、std::thread::sleep、算很久的东西,卡住的是池里的一个线程以及之后被分到那个线程的页面请求和 server function 调用(和 tokio 卷在 async 里阻塞是同一件事,只是这里每个线程是一个单线程调度器,没有别的 worker 来偷任务)。
首页单独请求是 0.6 毫秒,这里面包括新建一个 VirtualDom、跑一遍组件树、写 HTML:每个请求都从根开始跑,请求之间不共享任何组件状态。
状态码和响应头:在首块之前定下
闭包等完 suspense 之后,先看根错误边界(ScopeId::ROOT_ERROR_BOUNDARY)里有没有错误:
// check if there are any errors from the root error boundary
let error = virtual_dom.in_scope(ScopeId::ROOT_ERROR_BOUNDARY, || {
consume_context::<ErrorContext>().error()
});
有错误时依次尝试几种类型,后面的覆盖前面的:
| 错误里是 | 状态码 | 响应体 |
|---|---|---|
HttpError { status, message } | status | message,没有就是状态码的标准短语 |
http::StatusCode | 它自己 | 标准短语 |
ServerFnError::ServerError { code, message, .. } | code,不是合法状态码就 500 | message |
router 的 ParseRouteError | 404 | 解析失败的说明 |
| 以上都不是 | 500 | 错误的 to_string(),服务端打一行 Failed to render page |
挑到状态码就把 SSRError::HttpError 发进 oneshot,return。handler 那边用 SSRError 拼一个只有状态码和一段纯文本的响应:整页都不渲染,没有 <html>,也没有 content-type。实验应用的两个页面:
#[component]
fn Teapot() -> Element {
HttpError::im_a_teapot("short and stout")?;
rsx! { p { "unreachable" } }
}
$ curl --noproxy '*' -s -i http://127.0.0.1:18790/teapot
HTTP/1.1 418 I'm a teapot
content-length: 15
short and stout
$ curl --noproxy '*' -s -i http://127.0.0.1:18790/nope
HTTP/1.1 404 Not Found
content-length: 737
Failed to parse route Route did not match:
Attempted Matches:
1) Route 'Home' ('/') did not match:
Found additional trailing segments: nope
2) Route 'Block' ('/block') did not match:
Static segment 'block' did not match instead found 'nope'
...
7) Route 'Swap2' ('/swap2') did not match:
Static segment 'swap2' did not match instead found 'nope'
(去掉了 date 行,404 的正文中间删了几行。)/nope 这种路径进来,router 的 RouterContext::current 解析失败,throw_error(ParseRouteError { .. }),错误一路冒到根错误边界,于是变成 404,正文是 ParseRouteError 的消息:把路由枚举的每个变体和它的路径都列了出来,原样发给了访问者。只有冒到根错误边界的错误才变状态码。把同一个 Teapot 包进自己的 ErrorBoundary:
#[component]
fn Caught() -> Element {
rsx! {
ErrorBoundary { handle_error: |e: ErrorContext| rsx! { p { "caught: {e.error().unwrap()}" } },
Teapot {}
}
}
}
响应就是一个普通页面,状态码 200,错误消息只出现在 HTML 里:
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
...
</script><p data-node-hydration="0"><!--node-id1-->caught: 418 I'm a teapot: short and stout<!--#--></p><script>window.initial_dioxus_hydration_data=...
没有错误时,状态码和响应头来自 FullstackContext:
// Check the FullstackContext in case the user set the statuscode manually or via a layout.
let http_status = streaming_context.current_http_status();
let headers = streaming_context
.take_response_headers()
.unwrap_or_default();
// Now that we handled any errors from rendering, we can send the initial ok result
_ = initial_result_tx.send(Ok((http_status, headers)));
组件或 server function 的函数体里可以 FullstackContext::commit_http_status 改状态码、add_response_header 加响应头。take_response_headers 是 Option::take:发完这一次,response_headers 就是 None,之后的 add_response_header 找不到 HeaderMap,什么也不做;状态码也只读这一次。截止时刻就是这行 send,而它在哪取决于流式模式:Disabled 下在所有 suspense 完成之后,OutOfOrder 下在首块之前(router 调了 commit_initial_chunk,或者已经没有挂起的任务)。
实验页面在首次渲染时加一个头,在一个 suspense 边界里的 use_server_future 等完 300 毫秒之后再加一个头、把状态码改成 201:
#[component]
fn Hdr() -> Element {
#[cfg(feature = "server")]
if let Some(cx) = dioxus::fullstack::FullstackContext::current() {
cx.add_response_header(
dioxus::fullstack::http::HeaderName::from_static("x-early"),
dioxus::fullstack::http::HeaderValue::from_static("1"),
);
}
rsx! {
p { "header page" }
SuspenseBoundary { fallback: |_| rsx! { "loading" },
Late {}
}
}
}
#[component]
fn Late() -> Element {
let v = use_server_future(|| async move {
let out = sleep_ms(300).await;
// runs after the component suspended once
#[cfg(feature = "server")]
if let Some(cx) = dioxus::fullstack::FullstackContext::current() {
cx.add_response_header(
dioxus::fullstack::http::HeaderName::from_static("x-late"),
dioxus::fullstack::http::HeaderValue::from_static("1"),
);
dioxus::fullstack::FullstackContext::commit_http_status(
dioxus::fullstack::http::StatusCode::CREATED,
None,
);
}
out
})?;
rsx! { p { "late {v():?}" } }
}
默认配置下 curl 的响应头(去掉 date):
HTTP/1.1 201 Created
content-type: text/html; charset=utf-8
age: 0
x-early: 1
x-late: 1
transfer-encoding: chunked
同一个二进制,main 里读到环境变量就给 ServeConfig 加 enable_out_of_order_streaming(),换成乱序流式再请求一次:
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8
age: 0
x-early: 1
transfer-encoding: chunked
Disabled 下,等 suspense 发生在定状态码之前,晚到的头和状态码都赶上了。OutOfOrder 下,首块在 Late 还挂着时就发出去了,那时响应头已经被 take 走,x-late 和 201 都悄无声息地丢了,没有警告。(age: 0 是 RenderFreshness::write 写的,增量缓存那一页讲。)
渲染器什么时候回池
渲染器在第 4 步被 pop 出来,move 进了 create_render_future。整段闭包里只有一处把它还回去,就在最后:
stream.render(post_streaming);
renderer.reset_render_components();
myself.renderers.write().unwrap().push(renderer);
中途任何一个 return 都绕过了这一行:根错误边界里有错误(上面的 418、404、500 全在此列)、render_head 失败、throw_error! 宏(写主体、写尾部、流式替换块、写增量缓存失败时)。渲染任务被 ReceiverWithDrop 中途 abort 时也一样。这些情况下渲染器随闭包一起被 drop。
不会因此卡住:池空了,第 4 步的 unwrap_or_else(Self::pre_renderer) 会现造一个新的。丢掉的是那个渲染器的 template_cache,新渲染器要从头切每个模板的字符串段。所以一串 404(比如扫描器乱请求路径)会把池子里攒下的渲染器一个个消耗掉,之后的正常请求换成新渲染器、重新切模板。池子的大小也就不是固定的 4:并发高时现造的渲染器正常结束后都会 push 回去,池子最多涨到同时成功渲染的请求数,出错的请求再把它往下减。这一点在源码之外观察不到(池子是私有的),以上是读代码得出的。
上面的 Block 页面,在渲染时 std::thread::sleep 500 毫秒。服务端刚启动、没有别的请求,16 个 curl 同时发出。M2 Max(available_parallelism 是 12)、macOS 27、release 构建、tokio-util 0.7.20。几个请求的 time_total 接近 1 秒?
动脑筋答案
那次运行的 16 个时间(排序后,原样):
0.500887 0.501934 0.502850 0.504470 0.505052 0.505404 0.505703 0.505716 0.505824 0.505831 0.506109 0.506506 0.995513 0.998171 0.999937 1.001352
4 个。池里 12 个线程,spawn_pinned 每次挑计数最小的线程:前 12 个请求各占一个,计数都变成 1;后 4 个请求只能排到计数为 1 的线程上,而那个线程正被 std::thread::sleep 占着,一直等到前一个渲染结束才开始自己的 500 毫秒。页面里的线程 id 也印证了这一点:ThreadId(14) 到 ThreadId(17) 各出现两次。
小结
launch、serve、server::router最后都调serve_dioxus_application:先挂 server function 路由,再把public/里除index.html外的文件逐个挂上(release 下-dxhhash 文件名加一年的immutable缓存,debug 下子目录走ServeDir),其余路径进get(render_handler)。FullstackState持有一个available_parallelism个线程的LocalPoolHandle和一个初始 4 个渲染器的池;render_to在 axum 的线程上只建通道、spawn_pinned、等 oneshot,渲染整个在本地线程上做。spawn_pinned挑任务最少的线程;渲染里同步阻塞会让之后分到同一线程的页面请求和 server function 排队,只有静态文件不受影响。- 根错误边界里的错误决定状态码,响应只有状态码和一段纯文本;
ParseRouteError的 404 正文会列出全部路由。没有错误时状态码和响应头只在发首块前读一次,OutOfOrder下之后再设置会静默丢失。 - 渲染器只在成功走到最后时回池;出错或被中止时渲染器连同模板缓存一起丢掉,池子按需现造新的。