Rust in Everything
基于 dioxus 0.7.10

怎么读这一卷

主线位置:三条主线的总览。本页把更新主线、首屏主线和调用主线各画一遍,标出每一步落在哪个函数上、哪一页讲它;再讲这三条线为什么会分布在两份编译产物里:同一份代码按 feature 编成 wasm 客户端和服务端二进制,debug_assertions 又让 debug 和 release 走不同的代码。「一次点击」和「一次页面请求」用完整的例子把前两条线各走一遍。

flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1[01 全景]
  use1 --> use2["03 的 scope 与 hooks"]
  use2 --> use3["04 响应式"]
  use3 --> use4["06 的水合与 server function"]
  q -->|读懂实现| all[00 到 11 按顺序]

锁定版本

项本卷本站
dioxus 工作区(所有 dioxus-* crate、generational-box,同版本发布)0.7.10,tag v0.7.10,commit 57d6794(2026-07-30)Cargo.lock 里 0.7.9(tag v0.7.9,commit 3e43ffa,2026-05-07)
dx CLI实验用 0.7.90.7.9
实验依赖里的 axum / tokio / reqwest / wasm-bindgen0.8.9 / 1.54.1 / 0.12.28 / 0.2.129
rust-version / editiondioxus 1.83.0 / 2021,dioxus-core 1.76.0 / 2021

源码链接一律指向 tag:https://github.com/DioxusLabs/dioxus/blob/v0.7.10/packages/...。git diff --stat v0.7.9 v0.7.10 的结果是 6 个文件:

 Cargo.lock                        | 114 +++++++++++++++++++-------------------
 Cargo.toml                        | 104 +++++++++++++++++-----------------
 packages/cli/src/build/builder.rs |   4 +-
 packages/cli/src/build/link.rs    |   4 +-
 packages/cli/src/build/request.rs |  12 +++-
 packages/cli/src/serve/runner.rs  |   2 +-
 6 files changed, 128 insertions(+), 112 deletions(-)

根 Cargo.toml 和 Cargo.lock 的改动只有版本号,另外 4 个文件都在 CLI 里。所以本卷讲的运行时代码就是本站在跑的代码。

本站的 dx 是 0.7.9,本卷实验用同一个 dx 编 0.7.10 的库。dx 会打一行 ERROR,但照常编完:

  0.57s ERROR 🚫dx and dioxus versions are incompatible!
                  • dx version: 0.7.9
                  • dioxus versions: [0.7.10]

这是 CLI 启动时的版本检查,只要工作区里 dioxus 的最低版本高于 dx、或最高版本低于 dx,就用 tracing::error! 打这一行,不中断构建。

三条主线

全卷沿三条线展开,每页开头都会标出本页在哪条主线上。

更新主线:一次点击

fn app() -> Element {
  let mut count = use_signal(|| 0);
  rsx! {
    p { "count = {count}" }
    button { onclick: move |_| count += 1, "+" }
  }
}
flowchart TB
  clickev["click 冒泡到根"] --> handler["闭包找 dioxus-id"]
  handler --> he["handle_event"]
  he --> userfn["count += 1"]
  userfn --> drop1["写锁释放 通知订阅者"]
  drop1 --> dirty["mark_dirty"]
  dirty --> msg["发 Immediate"]
  msg --> ri["render_immediate"]
  ri --> diffn["run_scope 和 diff"]
  diffn --> muts["SetText 指令"]
  muts --> flush["flush_edits 交给 JS"]
  flush --> eff["下一轮跑 effect"]
步骤源码讲它的页
1. 冒泡事件只在根上 addEventListener 一次,不冒泡的挂在元素上:createListener。挂上去的 handler 是 WebsysDom::new 里用 Closure::wrap 包的 Rust 闭包,不经过序列化interpreter/src/ts/core.ts、web/src/dom.rs事件委托
2. 闭包里 walk_event_for_id 从事件目标往上找带 data-dioxus-id 的元素,把 web_sys::Event 包成 PlatformEventData,调 runtime.handle_event(name, event, element);返回后看用户有没有调 prevent_defaultweb/src/dom.rs、web/src/events/mod.rs事件委托
3. Runtime::handle_event 由 ElementId 找到挂载点,冒泡的事件沿父元素往上找同名监听器,不冒泡的只找目标core/src/runtime.rs事件
4. count += 1 是 Signal 上的语法糖,拿到写锁改值;锁释放时 SignalSubscriberDrop 的 drop 调 update_subscriberssignals/src/signal.rs、write.rsSignal
5. 每个订阅者 ReactiveContext::mark_dirty;组件的 context 往调度通道发 SchedulerMsg::Immediate(scope)core/src/reactive_context.rs、scope_context.rs、tasks.rsSignal、调度
6. web 的主循环 run 在 select! 里等 wait_for_work,它收到消息、把 scope 标脏后返回web/src/lib.rs、core/src/virtual_dom.rsVirtualDom 与 Runtime、WebsysDom 与 interpreter
7. render_immediate 循环 pop_work(脏 scope 按高度从根往下);run_and_diff_scope 先 run_scope 重跑组件得到新 VNodecore/src/virtual_dom.rs、scheduler.rs、scope_arena.rs调度、Scope 与 hooks
8. diff_node:新旧 VNode 是同一个 Template,只逐个比动态节点和动态属性,文本变了就调 set_node_textcore/src/diff/node.rsdiff、Template 与 VNode
9. 指令经过 WriteMutations 交给渲染器:WebsysDom 的实现把每条指令写进 sledgehammer 的缓冲,render_immediate 返回后主循环调 flush_edits,一次交给 JS 执行core/src/mutations.rs、web/src/mutations.rs、interpreter/src/unified_bindings.rsMutations、WebsysDom 与 interpreter
10. render_immediate 结尾 finish_render 发现有待跑的 effect 就发 SchedulerMsg::EffectQueued;effect 在下一次 wait_for_work 的 poll_tasks 里、DOM 已经更新之后才跑core/src/runtime.rs、virtual_dom.rs、effect.rsMemo 与 effect

第 1 步的「委托」只对冒泡事件成立;第 9 步没有等 requestAnimationFrame,web 主循环里等空闲时间、等动画帧的两行代码是注释掉的,render_immediate 之后立刻 flush_edits。逐步的实验见一次点击。

首屏主线:一次页面请求

flowchart TB
  req["GET 落到 fallback"] --> rh["render_handler"]
  rh --> pool["render_to"]
  pool --> pin["spawn_pinned"]
  pin --> vdom["新建 VirtualDom"]
  vdom --> rip["rebuild_in_place"]
  rip --> sus["等 suspense"]
  sus --> html["带水合标记的 HTML"]
  html --> data["写水合数据"]
  data --> wasm["浏览器加载 wasm"]
  wasm --> reb["不发指令的 rebuild"]
  reb --> rehy["rehydrate 认领节点"]
步骤源码讲它的页
1. dioxus::server::router(app) 调 serve_dioxus_application:先挂 server function,再挂静态文件,最后 .fallback(get(FullstackState::render_handler)),返回一个 axum::Routerfullstack-server/src/launch.rs、server.rs一次请求的渲染、axum 卷dioxus 生成的 Router
2. FullstackState::render_handler 把请求的 Parts 交给 SsrRendererPool::render_to,后者先查增量缓存,再 spawn_pinned 到一个本地线程:VirtualDom 是 !Sendfullstack-server/src/server.rs、ssr.rs一次请求的渲染、tokio 卷JoinSet 与 LocalSet
3. 每个请求新建一个 VirtualDom,根上提供 ServerDocument、请求信息、初始路由;rebuild_in_place 用 NoOpMutations 建出整棵树,不产生 DOM 指令fullstack-server/src/ssr.rs、core/src/virtual_dom.rs一次请求的渲染
4. wait_for_suspense 等挂起的任务;use_server_future 在服务端跑完 future,把结果存进它在 HydrationContext 里预留的槽位core/src/virtual_dom.rs、fullstack-core/src/server_future.rs、transport.rsSuspense 与错误边界、水合
5. dioxus_ssr::Renderer 在 pre_render = true 时带水合标记写 HTMLssr/src/renderer.rs、cache.rsdioxus-ssr
6. serialize_server_data 收集各个槽位,base64 后写进 window.initial_dioxus_hydration_data;还没好的 suspense 边界先发占位,好了再流出替换脚本fullstack-server/src/ssr.rs、streaming.rs水合、流式渲染
7. 浏览器里 web 主循环把 skip_mutations 设成 true,读出水合数据,在 HydrationContext 里 rebuild:use_server_future 第一次运行直接取服务端送来的值web/src/lib.rs、fullstack-core/src/server_future.rs水合
8. WebsysDom::rehydrate 沿服务端送来的 DOM 给节点分配 ElementId、挂监听器;之后就进入更新主线web/src/hydration/hydrate.rs、interpreter/src/ts/hydrate.ts水合

逐步的实验(curl 拿到的 HTML 每一块是什么)见一次页面请求。

调用主线:一次 server function

#[post("/api/double")]
async fn double(n: i32) -> Result<i32, ServerFnError> {
  Ok(n * 2)
}
flowchart TB
  mac["post 属性宏"] --> srv["cfg server 这一半"]
  mac --> cli["cfg not server 这一半"]
  srv --> inv["inventory 登记"]
  inv --> reg["启动时挂路由"]
  cli --> creq["ClientRequest::new"]
  creq --> fetch["编码参数 发请求"]
  fetch --> reg
  reg --> runfn["解码 执行 编码"]
  runfn --> dec["客户端解码成 Result"]
步骤源码讲它的页
1. #[post](入口是过程宏函数 post,还有 #[get]、#[server] 等)把函数改写成一个同名函数,函数体里两块代码分别挂着 #[cfg(not(feature = "server"))](客户端一半)和 #[cfg(feature = "server")](服务端一半)fullstack-macro/src/lib.rsserver function
2. 服务端一半:用户的函数体原样保留,另生成一个 axum handler,inventory::submit! 一个 ServerFunctionfullstack-macro/src/lib.rs、fullstack-server/src/serverfn.rsserver function
3. 启动时 register_server_functions 遍历 ServerFunction::collect(),每个 方法 路径 挂一条路由,日志里是 Registering: POST /api/doublefullstack-server/src/server.rsserver function、axum 卷dioxus 生成的 Router
4. 客户端一半:ClientRequest::new 造请求,ServerFnEncoder 按参数类型选编码,发出去;ServerFnDecoder 把响应解码成返回类型fullstack/src/client.rs、magic.rs、encoding.rsserver function、serde 卷
5. 错误统一成 ServerFnError,和 HTTP 状态码互相映射fullstack-core/src/error.rs、httperror.rsserver function
6. 服务端渲染时组件里调 double(..),走的是服务端那一半的最后一段:直接调用函数体,不发 HTTP;配合 use_server_future,结果进首屏主线的水合数据fullstack-macro/src/lib.rs水合

一份代码,编两次

三条主线横跨两份产物:更新主线大部分在浏览器的 wasm 里,首屏主线的前半段在服务端二进制里,调用主线两边各一半。它们来自同一个 crate、同一份 src/,区别全在编译时打开的 feature 上。

dx 怎么编

实验 crate 的 Cargo.toml 是 dioxus 模板的写法:

[dependencies]
dioxus = { version = "=0.7.10", features = ["router", "fullstack"] }

[features]
default = []
web = ["dioxus/web"]
server = ["dioxus/server"]

dx build --platform web --fullstack --verbose 的开头几行:

  0.57s DEBUG Found feature web for renderer web
  0.58s DEBUG Target Info:
                • features: ["web"]
                • triple: wasm32-unknown-unknown
                • bundle format: Web
...
  0.58s DEBUG Found feature server for renderer server
  0.58s DEBUG Target Info:
                • features: ["server"]
                • triple: aarch64-apple-darwin
                • bundle format: Server

dx 在用户 crate 的 [features] 里找「打开 dioxus/web 的那个 feature」和「打开 dioxus/server 的那个」,各起一次 cargo:客户端 --target wasm32-unknown-unknown --features web,服务端用本机 triple、--features server。两次都带 --no-default-features,再把默认集里不是渲染器的 feature 加回去。产物是 dx/<包名>/debug/web/server(服务端二进制)和它旁边的 public/(wasm、JS 胶水、index.html),服务端从 public/ 取静态文件。

feature 怎么往下传

dioxus 门面 crate 的 [features] 表 里,和这两次编译有关的是三个:

用户 crate打开的 dioxus feature往下打开
依赖里写死fullstackdep:dioxus-fullstack、dioxus-config-macro/fullstack、dep:serde、dioxus-web?/hydrate、dioxus-web?/document、dioxus-web?/mounted、dioxus-web?/devtools、dioxus-server?/document
web(客户端那次)webdep:dioxus-web、dioxus-fullstack?/web、dioxus-config-macro/web、dep:dioxus-cli-config、dioxus-cli-config?/web、dioxus-asset-resolver?/web
server(服务端那次)serverdep:dioxus-server、dioxus-fullstack?/server、dep:dioxus-fullstack-macro、ssr(dep:dioxus-ssr 和 dioxus-config-macro/ssr)、dioxus-liveview?/axum

?/ 的意思是「这个依赖已经被别的 feature 打开时,才顺带打开它的某个 feature」。所以 fullstack 自己不决定是客户端还是服务端:客户端那次 fullstack + web,dioxus-web 带上 hydrate(它的 hydrate 又拉进带 web feature 的 dioxus-fullstack-core),web 主循环里 should_hydrate 为真,首屏走水合;服务端那次 fullstack + server,dioxus-server 依赖 dioxus-fullstack-core 时写死了 features = ["server"]。默认集(launch、devtools、logger、lib)里没有任何渲染器。

门面 crate 的 src/lib.rs 按同样的 feature 决定导出什么:server 打开时才有 dioxus::server 和 dioxus::serve,fullstack 打开时 prelude 里才有 post、get、server、use_server_future、ServerFnError。它的 crate 文档还列着一个 hot-reload feature,Cargo.toml 里并没有这个 feature。

dioxus::launch(app) 在运行时选平台:LaunchBuilder::new 按 native、desktop、mobile、web、server、liveview 的顺序看哪个 feature 开着,launch 再分派:server 走 dioxus_server::launch_cfg(建 tokio 运行时、起 axum),web 走 dioxus_web::launch::launch。同一行 dioxus::launch(app),客户端那次编进去的是 web 的启动代码,服务端那次是 axum 的。

📝NOTE
没有蠢问题

问: 两个 feature 都开着会怎样?launch 的文档说优先级是 liveview、server、native、desktop、mobile、web。

答: 以代码为准。文档注释写的是 server 排在 web 前面,但 LaunchBuilder::new 里的 if 链是先判断 cfg!(feature = "web")、后判断 cfg!(feature = "server"),两个都开时选的是 KnownPlatform::Web。dx 每次只开一个,所以正常构建碰不到;自己用 cargo build --features web,server 时会碰到。文档和代码不一致的地方,本卷一律按代码写。

cfg 在哪里分叉

分叉发生在三个层次,作用的对象不一样:

写法在哪里求值例子
dioxus 各 crate 内部的 #[cfg(feature = "server")] / #[cfg(feature = "web")]那个 crate 自己,看 Cargo 合并后的 featuredioxus-fullstack 的 src/ 里有 48 行提到 feature = "server"、20 行提到 feature = "web";use_server_future 在服务端存结果、在 web 上先取水合数据
过程宏生成的 #[cfg(feature = "server")]展开到的地方,也就是用户 crate#[post]、#[get]、#[server] 的两半
server_only!、web!、fullstack! 这类配置宏过程宏 crate dioxus-config-macro 编译时的 featureserver_only! 看的是 ssr 或 liveview,条件不成立就展开成 ()

第二种最容易出问题。宏生成的 #[cfg(feature = "server")] 判断的是用户 crate 自己有没有一个叫 server 的 feature 并且打开了,和 dioxus 的 server feature 无关。用户 crate 不声明 server 时,rustc 的 check-cfg 会认为这是一个不存在的 cfg 值;宏在函数上加了 #[deny(unexpected_cfgs, reason = ...)](宏代码),把这个警告升级成错误,并把怎么改写进了 reason。把依赖写成 dioxus = { version = "=0.7.10", features = ["fullstack", "server"] }、不写 [features],cargo check 的输出(去掉了颜色,reason 里的两段 toml 示例省略):

error: unexpected `cfg` condition value: `server`
 --> src/main.rs:3:1
  |
3 | #[post("/api/double")]
  | ^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: no expected values for `feature`
  = note: using a cfg inside a attribute macro will use the cfgs from the destination crate and not the ones from the defining crate
  = help: try referring to `post` crate for guidance on how handle this unexpected cfg
  = note: see <https://doc.rust-lang.org/nightly/rustc/check-cfg/cargo-specifics.html> for more information about checking conditional configuration
  = note: 
          ==========================================================================================
            Using Dioxus Server Functions requires a `server` feature flag in your `Cargo.toml`.
            Please add the following to your `Cargo.toml`:
...
error: could not compile `intro-nofeat` (bin "intro-nofeat") due to 1 previous error

声明了 server = ["dioxus/server"]、但构建时没打开它,就不报错了,后果却更隐蔽。同一个 crate,依赖里仍然写死 dioxus/server,main 里 dioxus::launch(app):dioxus 的 server 开着,所以起的是 axum 服务端;用户 crate 的 server 没开,所以 double 编成了客户端那一半,没有登记。分别用 cargo build 和 cargo build --features server 编,起服务后 curl -X POST -d '{"n":21}' /api/double:

# cargo build
HTTP/1.1 405 Method Not Allowed
allow: GET,HEAD

# cargo build --features server
INFO dioxus_server::server: Registering: POST /api/double
HTTP/1.1 200 OK
content-type: application/json

42

第一种情况服务端日志里没有 Registering 这一行,POST 落到了只认 GET 的 fallback 上。dx 构建时按 [features] 里的名字开 feature,不会出现这种半开的状态;用 cargo 直接编服务端时要记得 --features server。

两份产物里各有什么

在 double 的函数体里加一行 println!("MARKER_IN_SERVER_FN_BODY n={n}"),组件的 rsx 里加一段文本 MARKER_IN_RSX,dx build 之后在两份产物里找这些字符串(grep -c -a,数的是匹配的「行」,非 0 就是存在):

server                           MARKER_IN_SERVER_FN_BODY   1
server                           MARKER_IN_RSX              1
server                           /api/double                1
public/wasm/intro-fs_bg.wasm     MARKER_IN_SERVER_FN_BODY   0
public/wasm/intro-fs_bg.wasm     MARKER_IN_RSX              1
public/wasm/intro-fs_bg.wasm     /api/double                1

组件两边都有:服务端要渲染它,客户端要水合、更新它。server function 的路径两边都有:服务端用来挂路由,客户端用来发请求。函数体只在服务端:数据库连接、密钥这类只该在服务端出现的代码,写在 server function 里就不会进 wasm;写在组件里、或者写在没被 cfg 包住的普通函数里,就会两边都编。release 构建(--release)的两份产物结果相同。

debug 构建多出来的东西

debug_assertions 在 debug profile 下打开、release 下关闭。dioxus 有两类这样的代码:库里自己的 #[cfg(debug_assertions)],以及宏生成到用户 crate 里的(rsx! 生成的代码里就有,看的是用户 crate 的 profile)。和本卷有关的:

位置debug 下多做什么讲它的页
rsx! 的展开(rsx/src/template_body.rs)模板不是一个 static Template,而是从一个以文件、行、列为 key 的 GlobalSignal 里读,没被热重载替换过就用原始模板,再由 DynamicValuePool 渲染;release 直接用 static ___TEMPLATErsx! 展开成什么
VirtualDom::newregister_subsecond_handler:hot-patch 之后发 SchedulerMsg::AllDirty,所有组件重跑VirtualDom 与 Runtime
use_hook 取错类型时有 hot-patch 跳转表时替换 hook 的值,否则 panic(「rules of hooks」)Scope 与 hooks
带 key 的列表 diff检查同一层的 key 不重复带 key 的列表
ReactiveContext、Signal 写锁记录创建和写入的调用位置,trace 日志里带上;ScopeId 的 Debug 输出带组件名Signal
generational-box 的错误记录值在哪里创建、借用在哪里发生,报错时打印generational-box
web 主循环开着 devtools feature 时连 /_dioxus 的 WebSocket 收热重载消息;水合前先等 100ms 看有没有补丁WebsysDom 与 interpreter
水合数据服务端多写 initial_dioxus_hydration_debug_types 和 _debug_locations,客户端取数据出错时用来报告类型和调用位置水合
server function 的 handler panic响应体是 Server function panicked: ...,release 是 Internal Server Errorserver function
服务端启动release 直接 axum::serve;debug 走一个支持热重载的服务循环;public/ 下的子目录 debug 用 ServeDir 动态提供(新生成的补丁文件立刻可见),release 在建路由时遍历一次一次请求的渲染
router 的 Link、GoBackButton没有外层 Router 时 panic,release 只打日志、渲染成空Router 运行时

同一个应用分别 dx build 和 dx build --release,curl 首页拿到的水合脚本:

<!-- debug -->
<script>window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg==";window.initial_dioxus_hydration_debug_types=["core::option::Option<dioxus_core::render_error::CapturedError>","unknown","unknown","unknown","unknown","unknown","core::result::Result<i32, dioxus_fullstack_core::error::ServerFnError>"];window.initial_dioxus_hydration_debug_locations=["~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/dioxus-server-0.7.10/src/ssr.rs:556:29","unknown","unknown","unknown","unknown","unknown","src/main.rs:11:17"];</script>

<!-- release -->
<script>window.initial_dioxus_hydration_data="hIEY9vb2hRihGGIYTxhrAg==";</script>

数据本身两边一样。debug 多出来的两个数组按槽位对齐:第 0 个槽位是根 suspense 边界的错误(Option<CapturedError>),最后一个是 src/main.rs 第 11 行那个 use_server_future 的 Result<i32, ServerFnError>。调用位置是编译机上的绝对路径,这些信息只在 debug 构建的页面里出现。热重载那一行也可以在 wasm 里找到:debug 的 intro-fs_bg.wasm 里有 _dioxus 这个字符串,release 的没有。两份 wasm 的大小是 52 376 105 和 2 485 919 字节,服务端二进制是 27 934 296 和 6 449 176 字节。

本卷展示输出时都会说明是 debug 还是 release;两者行为不同时两种都跑。

阅读路线

前置知识

  • React 式的组件、props 和 hooks。dioxus 的 FAQ 写着「we borrowed hooks from React」。
  • async/await。web 上的 future 由 VirtualDom 自己的任务表 poll,服务端渲染跑在 tokio 的本地线程上,见 tokio 卷。
  • axum 的 Router、handler、fallback、中间件,见 axum 卷:dioxus::server::router 返回的就是一个 axum::Router。
  • serde:server function 的参数和返回值、水合数据都经过 serde,见 serde 卷。

小结

  • 全卷对着 dioxus 0.7.10 写;本站用 0.7.9,两个 tag 之间只改了 CLI 的 4 个文件和版本号,运行时代码相同。
  • 更新主线:根上的委托监听器 → wasm 闭包找 data-dioxus-id → handle_event → signal 写锁释放 → mark_dirty → SchedulerMsg::Immediate → render_immediate → run_scope + diff → WriteMutations → flush_edits → effect。
  • 首屏主线:axum fallback → SsrRendererPool::render_to → 本地线程上每请求一个 VirtualDom → 等 suspense → dioxus_ssr 带水合标记 → 水合数据 → 客户端 rebuild + rehydrate。
  • 调用主线:#[post] 生成两半,服务端 inventory 登记、启动时挂路由,客户端编码发请求、解码成 Result<T, ServerFnError>。
  • dx 编两次:wasm 带用户的 web feature,服务端带 server,都带 --no-default-features。server function 的两半由宏生成到用户 crate 里的 #[cfg(feature = "server")] 切开,所以用户 crate 必须声明并打开自己的 server feature。
  • debug 构建多了热重载、水合调试信息和一些检查,release 里这些代码不存在;两者行为不同时本卷两种都跑。

💬 关联讨论

发起讨论
加载中...