怎么读这一卷
主线位置:三条主线的总览。本页把更新主线、首屏主线和调用主线各画一遍,标出每一步落在哪个函数上、哪一页讲它;再讲这三条线为什么会分布在两份编译产物里:同一份代码按 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.9 | 0.7.9 |
| 实验依赖里的 axum / tokio / reqwest / wasm-bindgen | 0.8.9 / 1.54.1 / 0.12.28 / 0.2.129 | |
rust-version / edition | dioxus 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_default | web/src/dom.rs、web/src/events/mod.rs | 事件委托 |
3. Runtime::handle_event 由 ElementId 找到挂载点,冒泡的事件沿父元素往上找同名监听器,不冒泡的只找目标 | core/src/runtime.rs | 事件 |
4. count += 1 是 Signal 上的语法糖,拿到写锁改值;锁释放时 SignalSubscriberDrop 的 drop 调 update_subscribers | signals/src/signal.rs、write.rs | Signal |
5. 每个订阅者 ReactiveContext::mark_dirty;组件的 context 往调度通道发 SchedulerMsg::Immediate(scope) | core/src/reactive_context.rs、scope_context.rs、tasks.rs | Signal、调度 |
6. web 的主循环 run 在 select! 里等 wait_for_work,它收到消息、把 scope 标脏后返回 | web/src/lib.rs、core/src/virtual_dom.rs | VirtualDom 与 Runtime、WebsysDom 与 interpreter |
7. render_immediate 循环 pop_work(脏 scope 按高度从根往下);run_and_diff_scope 先 run_scope 重跑组件得到新 VNode | core/src/virtual_dom.rs、scheduler.rs、scope_arena.rs | 调度、Scope 与 hooks |
8. diff_node:新旧 VNode 是同一个 Template,只逐个比动态节点和动态属性,文本变了就调 set_node_text | core/src/diff/node.rs | diff、Template 与 VNode |
9. 指令经过 WriteMutations 交给渲染器:WebsysDom 的实现把每条指令写进 sledgehammer 的缓冲,render_immediate 返回后主循环调 flush_edits,一次交给 JS 执行 | core/src/mutations.rs、web/src/mutations.rs、interpreter/src/unified_bindings.rs | Mutations、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.rs | Memo 与 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::Router | fullstack-server/src/launch.rs、server.rs | 一次请求的渲染、axum 卷dioxus 生成的 Router |
2. FullstackState::render_handler 把请求的 Parts 交给 SsrRendererPool::render_to,后者先查增量缓存,再 spawn_pinned 到一个本地线程:VirtualDom 是 !Send | fullstack-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.rs | Suspense 与错误边界、水合 |
5. dioxus_ssr::Renderer 在 pre_render = true 时带水合标记写 HTML | ssr/src/renderer.rs、cache.rs | dioxus-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.rs | server function |
2. 服务端一半:用户的函数体原样保留,另生成一个 axum handler,inventory::submit! 一个 ServerFunction | fullstack-macro/src/lib.rs、fullstack-server/src/serverfn.rs | server function |
3. 启动时 register_server_functions 遍历 ServerFunction::collect(),每个 方法 路径 挂一条路由,日志里是 Registering: POST /api/double | fullstack-server/src/server.rs | server function、axum 卷dioxus 生成的 Router |
4. 客户端一半:ClientRequest::new 造请求,ServerFnEncoder 按参数类型选编码,发出去;ServerFnDecoder 把响应解码成返回类型 | fullstack/src/client.rs、magic.rs、encoding.rs | server function、serde 卷 |
5. 错误统一成 ServerFnError,和 HTTP 状态码互相映射 | fullstack-core/src/error.rs、httperror.rs | server 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 | 往下打开 |
|---|---|---|
| 依赖里写死 | fullstack | dep:dioxus-fullstack、dioxus-config-macro/fullstack、dep:serde、dioxus-web?/hydrate、dioxus-web?/document、dioxus-web?/mounted、dioxus-web?/devtools、dioxus-server?/document |
web(客户端那次) | web | dep:dioxus-web、dioxus-fullstack?/web、dioxus-config-macro/web、dep:dioxus-cli-config、dioxus-cli-config?/web、dioxus-asset-resolver?/web |
server(服务端那次) | server | dep: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 的。
问: 两个 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 合并后的 feature | dioxus-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 编译时的 feature | server_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 ___TEMPLATE | rsx! 展开成什么 |
VirtualDom::new | register_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 Error | server 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;两者行为不同时两种都跑。
阅读路线
- 只想会用:读 01 全景;03 里的 Scope 与 hooks 和 Suspense 与错误边界;04 响应式;06 里的水合和 server function;遇到报错再查 09 常见坑。
- 想读懂实现:按顺序读。03 是全卷的重点;02 讲清楚
rsx!产出的Template之后,03 的 diff 和Mutations才好读。 - 只关心服务端:01 的一次页面请求、06 SSR 与 fullstack、10 在本站中,配合 axum 卷。
- 想自己写一个:读完 02 和 03,直接去 11 动手。
前置知识
- 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 带用户的webfeature,服务端带server,都带--no-default-features。server function 的两半由宏生成到用户 crate 里的#[cfg(feature = "server")]切开,所以用户 crate 必须声明并打开自己的serverfeature。- debug 构建多了热重载、水合调试信息和一些检查,release 里这些代码不存在;两者行为不同时本卷两种都跑。