设计取舍
主线位置:三条主线的汇总。更新主线上的取舍是「编译期模板加 diff」「整组件重跑」「
Copy的 signal」「读时订阅」「hook 按序号」「渲染器只收指令」;首屏主线上的是「单线程渲染池」「每个请求一个VirtualDom」「默认不流式」「水合按槽位」;调用主线上的是「server function 就是 HTTP 端点」;「路由是枚举」横跨更新和首屏两条线,「热重载只在 debug」横跨全部三条。每一条只说收益和代价,机制都在链接指向的页面里。
flowchart TB upd[更新主线] --> t1["编译期模板加 diff"] upd --> t2["整组件重跑"] upd --> t3["Copy 的 signal"] upd --> t4["读时订阅"] upd --> t5["hook 按序号"] upd --> t6["指令与事件委托"] first[首屏主线] --> t7["单线程渲染池"] first --> t8["每请求一个 VirtualDom"] first --> t9["默认不流式"] first --> t10["水合按槽位"] callm[调用主线] --> t11["server fn 即端点"] t5 --> t10 t7 --> t11 rt[router] --> t12["路由是枚举"] dbg["debug 构建"] --> t13["热重载代码"]
每一节回答三个问题:dioxus 选了什么,换来了什么,付出了什么。和别的框架对比时只用两种依据:dioxus 自己的文档(引文和出处在 dioxus 解决什么问题),以及不必实验的通用常识(会注明)。本卷没有跑过 React、Leptos、Sycamore 的代码,不比速度。
先列一张表,下面逐条展开。「证据」一栏是本卷里能看到这个代价的页面。
| 取舍 | 换来的 | 付出的 | 证据 |
|---|---|---|---|
| 1. 编译期模板加虚拟 DOM | 普通的 Rust 控制流;同一棵树交给任何渲染器;静态结构不进 diff | 每次更新整个组件函数重跑;for 生成的项每次都比;if / else 换分支整块替换 | dioxus 解决什么问题、diff |
| 2. 整组件重跑,props 相等才跳过 | 子组件默认不跟着父组件重跑 | 闭包和 children 按指针比,每次都重跑;props 每次渲染克隆一份 | #[component] 与 Props |
3. Copy 的 signal | 句柄随便移进闭包和 async 块,没有生命周期 | 借用检查挪到运行期;值跟着 scope 回收;SyncSignal 冲突是死锁 | generational-box、Signal |
| 4. 读时自动订阅 | 不写依赖数组 | 渲染时先读后写无限重跑且无警告;哪里的读算数要记规则 | Signal、Memo 与 effect |
| 5. hook 按调用序号 | hook 没有名字,取值是一次下标加 downcast | 放进条件里:类型不同 panic,类型相同静默串值 | Scope 与 hooks、带 key 的列表 |
| 6. 渲染器只收指令,web 上事件委托 | 一个内核多个渲染器;一次更新一次跨边界调用 | 事件处理是同步的一次调用,prevent_default 只认第一个 .await 之前 | Mutations、事件委托 |
| 7. 单线程运行时 | 没有锁,任务不要求 Send | 服务端每个请求钉在一个本地线程上,阻塞挡住同线程的页面和 server function | 一次请求的渲染,本页实验 |
8. 每个请求一个新 VirtualDom | 请求之间不共享 dioxus 状态 | 每次从头跑组件树;跨请求复用只有增量缓存,它会串用户 | context 与全局 signal、增量渲染与配置 |
| 9. 默认等所有 suspense 再发 | 状态码、响应头、整页内容在首字节前定下 | 首字节要等最慢的数据;打开乱序流式后状态码和头在首块后改不了 | 一次页面请求、流式渲染 |
| 10. 水合按槽位顺序 | 数据不用起名字,只有下标 | 两边 hook 顺序必须一致,否则整页水合中断或静默串值 | 水合 |
| 11. server function 就是 HTTP 端点 | 一个签名两边各一半,函数体只进服务端 | 公开可调用,鉴权自己做;参数编码和状态码有限制 | server function |
| 12. 路由是枚举 | 编译期生成解析和写回,运行时没有路由表 | 往返不转义 /、%、&;catch-all 404 是 200;字符串目标可能整页刷新 | Routable、Router 运行时 |
| 13. 热重载只在 debug 构建里 | release 里没有这些代码 | debug 和 release 是两种程序:模板、检查、错误信息都不同 | 怎么读这一卷 |
1. 编译期模板加虚拟 DOM
选择。 rsx! 在编译期把树里不会变的部分切成 Template,组件函数每次运行只新建一个带动态值的 VNode;diff 发现新旧两个 VNode 用的是同一个模板,就只比动态节点和动态属性(rsx! 展开成什么、diff)。
换来。
- 组件里的
if、for、match就是普通的 Rust 控制流,每次重跑重新求值,不需要框架自己的控制流原语。 - core 只认
WriteMutations:web 上是WebsysDom,测试里是收进Vec的Mutations,SSR 干脆不用指令、直接读树写 HTML(dioxus 解决什么问题)。 - 静态结构的代价在编译期付掉:那一页的实验里十四个元素的组件首次渲染只有 4 条指令,改一次 signal 只有 1 条
SetText,十个静态li根本不进 diff。
付出。
- 组件函数整个重跑。同一个实验里
app ran 2 time(s):只变了一行文本,整个函数和它的所有 hook 调用都又走了一遍。 - 比较的范围由模板限定,动态内容照样逐项比。把十个静态
li换成for循环,首次渲染从 4 条指令变成 35 条;更新仍是 1 条,但十个新VNode每次都和旧的比一遍(同页动脑筋)。 - 模板是替换的最小单位。
if/else的两个分支是两个模板,换分支就整块新建、ReplaceWith顶掉旧的,哪怕只差一个标签名,旧分支里的组件状态全部销毁(diff)。 - release 下模板是
static,按指针比;debug 下为了热重载在运行期构造、按值比(第 13 条)。
对照。 dioxus 0.6 的 README 说 Leptos「uses signals to drive both reactivity and rendering, while Dioxus uses signals just for reactivity」,FAQ 说 Sycamore「requiring no VDOM」;0.7 删掉了整节比较。按这个描述,分界在「signal 只决定谁重跑」还是「signal 直接改 DOM」:dioxus 选了前者,所以有重跑和 diff 的成本,也有普通控制流和多渲染器的好处。两边谁快,本卷没有测。
2. 整组件重跑,props 相等才跳过子组件
选择。 diff 遇到两个同一函数的组件节点,调 props 的 memoize:PartialEq 相等就跳过子组件,不等才把新值搬进旧 props、重跑(#[component] 与 Props)。
换来。 子组件默认不跟着父组件重跑。那一页的实验里父组件跑了 3 次,Fixed { n: 1 } 和无参数的 NoProps 都只跑了 1 次;ReadSignal 参数的子组件 diff 时跳过,只有真读了它的那个在值变了以后重跑。
付出。
- 闭包和
children打破这个机制。EventHandler/Callback的相等是比指针,rsx!里每次写的闭包都是新的;children: Element比的是Rc指针。实验里WithHandler、WithChildren父组件跑 3 次它们也跑 3 次,值没变也一样。闭包可以换成use_callback返回的同一个句柄(实验里 4 次渲染只跑 1 次);children在 0.7.10 里没有办法,按值比较Element的提议是 issue #1929。 - props 每次渲染都
Clone一份交给组件函数,大块数据要放进Rc或 signal。 - 子组件重跑不等于改 DOM,但组件函数和它的 hook 实打实地跑了。
对照(通用常识)。 React 的 React.memo 也是比 props,内联箭头函数每次渲染都是新对象,要靠 useCallback 稳住。dioxus 在这一点上和 React 是同一类问题、同一类解法。
3. Copy 的 signal:值在 generational-box 里
选择。 状态放进 generational-box 的格子,句柄只是「格子地址 + 代数」,release 下 16 字节、和 T 无关,所以 Copy;格子属于创建它的 scope 的 Owner,scope 删掉时代数加一、值清空(generational-box)。
这是 0.5 换上的。0.4 的组件签名是 fn app(cx: Scope) -> Element,Scope 是带生命周期的 &'a Scoped<'a, T>,状态用 use_state / use_ref。signal 那时已经在了:v0.4.3 的 dioxus-signals 里有 use_signal,第一个参数还是 cx: &ScopeState,底下就是 generational-box(这个 crate 第一次出现在仓库的 v0.4.1 tag 里)。#1791 在 0.5.0 去掉了 Scope、use_state、use_ref,PR 描述的原文见 dioxus 解决什么问题的冷知识;v0.5.0 的 dioxus-hooks 里已经没有 use_state 模块。
换来。 句柄可以移进任意多个事件闭包和 async 块,不用 clone,组件函数没有参数也没有生命周期。对照 Rc<RefCell<T>>(通用常识):同样是运行期借用检查,但每个闭包要先 clone 一份。
付出。 借用检查挪到了运行期,而且多出了「值已回收」这种 Rc 没有的错误:
- 同一个变量上先
write后read,编译期 E0502;复制出来的句柄冲突,运行期 panicAlreadyBorrowedMut/AlreadyBorrowed;读守卫跨.await,编译器不拦(Signal、already-borrowed)。 - 值的寿命跟 scope,不跟句柄。子组件的 signal 交给父组件,子组件卸载后再读得到
ValueDroppedError;debug 有copy_value_hoisted警告,release 只有一句「value was dropped」(generational-box、value-dropped)。 SyncSignal的格子是parking_lot::RwLock,同一线程上拿着写锁再读不报错,直接卡死。- release 的错误不带位置,「写 + 写」还被报成「already borrowed immutably」。
- 格子
Box::leak出来以后永远不还给分配器,只在线程局部的空闲列表里复用。
4. 读时自动订阅,不写依赖数组
选择。 read 订阅线程局部栈顶的 ReactiveContext;组件、memo、effect、resource 各有一个,每次运行前先退订、只留这一次读到的;写锁释放时逐个 mark_dirty(Signal)。
换来。 依赖就是「读过的东西」,不用手写列表;#1791 的收益清单里第一条就是「No more dependency arrays」。一次事件里写三次 signal,组件只重跑一次(调度)。
付出。 订阅是隐式的,漏了和多了都不报错,要记住「哪里的读算数」:
- 渲染时先读后写同一个 signal,组件每次都把自己标脏,无限重跑。0.6 的 debug 构建对此有警告,0.7 被 #4024 整个删掉,现在 debug 和 release 一样没有任何提示;
use_resource里读后写同一个 signal 也会无限重启(Signal、异步、write-during-render)。 - 不在任何 context 里的读不订阅:事件回调、
spawn和use_future的 async 块。use_server_future只跟踪造 future 的那个闭包,在 async 块里读的 signal 水合后变了也不重新请求(一次页面请求、use-resource-on-server)。 - 普通 props 不是依赖:
use_memo/use_effect里读 props,props 变了不重算,要use_reactive!或改成ReadSignal(Memo 与 effect、stale-props)。 - 写法上的细节也决定订阅:
count += 1不订阅,"{count}"订阅;memo 既由自己的任务重算、又在脏读时同步重算,闭包可能跑两次。
对照(通用常识)。 React 的 useEffect、useMemo 由开发者写依赖数组,漏写得到旧值。dioxus 把「写对数组」换成了「知道哪里的读算数」,出错的形状从旧值变成了不重跑或无限重跑。
5. hook 按调用序号存取
选择。 所有 hook 落到 Scope::use_hook:只凭序号和类型(downcast_ref)取值,hook 没有名字,也不记调用位置(Scope 与 hooks)。
换来。 不需要注册表,取值是一次 Vec 下标加一次 downcast;hook 的写法和 React 一样,dioxus 的 FAQ 直说「we borrowed hooks from React」。
付出。
- hook 放进条件里,类型不同就在
push_hook_valuepanic「Unable to retrieve the hook that was initialized at this index」;类型相同则静默拿到别人的值。debug 和 release 一样,只有dx serve热补丁下才改成覆盖(hook-in-condition)。 - 「按位置认身份」不止 hook 一处。列表不带 key 时按位置配对,中间插一项,组件状态留在原位置、和数据错位(实验里第二行变成
X | state of B);重复 key 只在 debug 构建、更新时检查(带 key 的列表、missing-key)。 - 水合数据沿用了同样的思路,见第 10 条。
6. 渲染器只收一串指令,web 上事件委托到根
选择。 VirtualDom 和渲染器之间只有 WriteMutations 的十五个方法,指令是一台栈机器(Mutations)。web 上 WebsysDom 把指令编码进 sledgehammer 的缓冲,render_immediate 之后 flush_edits 一次交给 JS,没有按帧攒批(等动画帧的代码自 #640 起注释掉了);冒泡事件在根元素上每种只挂一个 wasm 闭包,元素上只有 data-dioxus-id(WebsysDom 与 interpreter、事件委托)。
换来。 同一个内核接多个渲染器;一次 SetText 跨边界 21 字节,加 100 行 3204 字节,都只调一次 run();DOM 上不挂闭包,换监听器闭包不发任何指令(一次点击)。
付出。
- 事件处理是一次同步的
handle_event,prevent_default在它返回后才被读取:async 回调里第一个.await之后调用无效。0.7 起onsubmit不再默认阻止提交,不调就整页刷新(事件委托、prevent-default)。 stop_propagation只挡 dioxus 自己的监听器;根以下的原生stopPropagation会让 dioxus 完全收不到事件。- 0.7.10 的
RemoveEventListener先删data-dioxus-id再摘监听器,同一元素上剩下的监听器失效(remove-listener)。这一条是实现问题,不是设计本身,见文末。
7. 单线程运行时,服务端靠本地线程池
选择。 VirtualDom 用 Rc<Runtime> 和其他部分共享状态,Runtime 的字段全是 Cell / RefCell,所以 VirtualDom 是 !Send(VirtualDom 与 Runtime)。
换来。 wasm 上本来只有一个线程,不必为不存在的并发付锁和原子操作的成本;任务不要求 Send,组件和闭包里可以放 Rc、RefCell、web_sys 对象。唯一的例外是 ReactiveContext,它被 SyncSignal 的类型约束逼进了 SyncStorage(generational-box 的没有蠢问题)。
付出。 服务端是多线程的 tokio,!Send 的东西不能在 axum 的 worker 之间挪动。dioxus 的办法是一个 tokio-util 的 LocalPoolHandle(available_parallelism 个线程),页面渲染由 render_to 用 spawn_pinned 整个交给其中一个线程(一次请求的渲染)。每个线程上的调度器随 tokio-util 版本变:0.7.19 及以前是 current_thread 运行时加 LocalSet,0.7.20 是 LocalRuntime,本站的 Cargo.lock 里是 0.7.18;两者都是「一个线程、只跑本线程的任务」(见 tokio 卷的 JoinSet、LocalSet、task_local)。
server function 的 handler 用的是同一个池。make_handler 里第 76 行同样是 state.rt.spawn_pinned,注释是:
// Allow !Send futures by running in the render handlers pinned local pool
let result = state.rt.spawn_pinned(move || async move {
所以渲染里同步阻塞,挡住的不只是页面。实验应用 design-exp/fs 抄自 一次请求的渲染 用的那个,Block 页面在渲染时 std::thread::sleep 500 毫秒,另加一个返回当前线程 id 的 server function:
#[get("/api/tid")]
async fn tidfn() -> Result<String, ServerFnError> {
Ok(tid())
}
#[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}" } }
}
dx build --platform web --fullstack --release 后启动服务端。先同时发 12 个 /block 占满 12 个线程,100 毫秒后同时请求一个带 hash 的静态 JS 文件、/api/tid 和首页,做两轮:
B=http://127.0.0.1:18840
JS=$(curl --noproxy '*' -s $B/ | grep -o '/assets/[^"]*\.js' | head -1)
for round in 1 2; do
for i in $(seq 1 12); do
curl --noproxy '*' -s -o /dev/null $B/block &
done
sleep 0.1
curl --noproxy '*' -s -o /dev/null -w " static js %{http_code} %{time_total}\n" $B$JS &
curl --noproxy '*' -s -o /dev/null -w " server fn %{http_code} %{time_total}\n" $B/api/tid &
curl --noproxy '*' -s -o /dev/null -w " page / %{http_code} %{time_total}\n" $B/ &
wait
echo "round $round done"
done
输出(M2 Max、macOS 27、rustc 1.99.0,release 构建,tokio-util 0.7.20,available_parallelism 是 12):
static js 200 0.000756
page / 200 0.392506
server fn 200 0.393217
round 1 done
static js 200 0.001042
server fn 200 0.392750
page / 200 0.393635
round 2 done
server function 确实跑在池线程上:另一个脚本先在空闲时调一次 /api/tid,再在 12 个 /block 期间依次调四次(第二列是 time_total),最后请求首页:
idle: tid fn -> "ThreadId(14)"
"ThreadId(14)" 0.397632
"ThreadId(14)" 0.000602
"ThreadId(14)" 0.000888
"ThreadId(14)" 0.000575
page / during blocks: 0.000697
ThreadId(14) 是服务端日志里 block start on ThreadId(14) 到 ThreadId(25) 这 12 个池线程之一。第一次调用等到那个线程上的 /block 睡完;它返回时 12 个 /block 都已经睡完了,后面三次调用和最后的首页请求(脚本里的标签写着 during blocks,实际已在阻塞之后)都不用再等。这也说明了顺序请求为什么测不出这个效果:排在第一个的请求把阻塞的时间吃掉了。
- 静态文件不受影响:它们的 handler 直接跑在 axum 的多线程运行时上,不进这个池。
- 页面和 server function 都等了约 0.39 秒,就是那一轮
/block剩下的睡眠时间(500 毫秒减去先睡掉的 100 毫秒)。spawn_pinned挑任务最少的线程,12 个线程都在睡,挑到哪个都要等。 - 换成在
use_server_future里.await,线程会在.await处让出,同一线程上的渲染交替进行(一次请求的渲染的/wait实验)。
和 tokio 卷的在 async 里阻塞是同一件事,区别是这里每个线程是一个单线程调度器,没有别的 worker 来偷任务;修法见 blocking-in-render。
8. 每个请求一个新 VirtualDom
选择。 FullstackState 存的不是一个 VirtualDom,而是一个造 VirtualDom 的闭包(build_virtual_dom 字段);每个页面请求在钉住的线程上调一次,在根上放进这个请求的 ServerDocument、FullstackContext 和 MemoryHistory,渲染完就丢(一次页面请求)。
换来。 请求之间不共享任何 dioxus 状态,也不用在请求之间清理。GlobalSignal 的值存在每个 VirtualDom 的根上,两个并发请求各自看到 global=1(context 与全局 signal);VirtualDom 生在线程上、死在线程上,!Send 不成问题。
付出。
- 每个请求都从头跑整棵组件树、重新取数据。能跨请求复用的只有渲染器池里按模板切好的字符串段,渲染器还只在成功渲染完时才回池(dioxus-ssr、一次请求的渲染)。
- 想复用整页只有增量缓存:它对所有路由生效,键只有
path?query,不看 cookie;带用户信息的页面一旦被缓存,下一个人看到的是上一个人的页面;0.7.10 的内存缓存从不过期(增量渲染与配置、incremental-cache)。 - 这层隔离管不到
static:static+Mutex/ 原子量是进程级的,并发请求互相看到对方的写入(global-state-ssr)。 - 不在任何
VirtualDom里的代码(HTTP 调用的 server function、axum 中间件)读全局 signal 会 panic「Must be called from inside a Dioxus runtime.」(outside-runtime)。
9. 默认等所有 suspense 完成再发
选择。 StreamingMode 的 #[default] 是 Disabled:wait_for_suspense 等所有 use_server_future 完成,再一次写出整页。这个开关是 0.6.0 的 #3288 加的,在那之前只有乱序流式一种做法(一次页面请求的冷知识)。
换来。 首字节发出之前,状态码、响应头和整页内容都已经定了:错误能映射成状态码,没有 JavaScript 的客户端也能看到完整内容。
付出。
- 首字节要等最慢的那份数据。一次页面请求里
time_total是 0.106 秒,几乎全是那个 100 毫秒的 server function,在它返回之前连状态码都没发。 - 打开
OutOfOrder换来先发首块,代价是另一组:首块一发,之后设置的状态码和响应头静默丢失;挂起的边界先写 fallback,内容藏在页面末尾,没有 JS 的用户只看到 fallback;发出去的边界被freeze,之后的错误只能送到客户端,没有ErrorBoundary时 HTTP 200 的页面会被 wasm 整页换成错误;没有 router 的应用不手动commit_initial_chunk就等于不流式(流式渲染、header-after-commit)。
10. 水合按槽位顺序对齐
选择。 HydrationContext 是一串只有下标的槽位,create_entry 按调用顺序领下标;服务端每个 scope 一份,按客户端建树的顺序合并;客户端在同一个顺序里依次取回(水合)。
换来。 不用给每份数据起名字,没有名字冲突;数据就是 CBOR 编码的槽位数组,一次页面请求那一页只有一个 use_server_future,整份水合数据 16 字节。
付出。 两边创建槽位的顺序必须一致,而没有任何一条报错直接说「顺序不一致」:
- 类型不同:客户端反序列化失败、自己去请求、组件挂起,
hydrate_node抛TypeError,整页停在服务端发来的样子,按钮点了没反应。 - 类型相同:静默串值,页面上仍是服务端的内容,第一次重渲染时两个值换了位置。
- 会造成顺序不一致的写法:
use_server_future放在依赖cfg!、window、时间、随机数的条件里;只在一边存在的组件;static在两边取值不同导致首帧结构不同(hydration-mismatch、context 与全局 signal)。
这和第 5 条是同一个选择在两处的体现:hook 按序号存值,水合数据按序号对齐,二者都要求「每次调用顺序一样」,后者还要求服务端和客户端两份不同的编译产物顺序一样。
11. server function 就是一个 HTTP 端点
选择。 #[post] / #[get] / #[server] 把一个 async fn 改写成两半,由宏生成到用户 crate 里的 #[cfg(feature = "server")] 切开:客户端那一半编码参数、发请求、解码响应;服务端那一半用 inventory 登记成一条 axum 路由,SSR 时直接调用函数体(server function)。
换来。 一个签名,两边的参数和返回类型由编译器保证一致;函数体只编进服务端二进制(怎么读这一卷里在两份产物中找字符串的实验);SSR 时不走 HTTP。
付出。
- 每个 server function 都是公开路由,不经过任何 dioxus 客户端就能 curl;客户端加的
X-Request-Client: dioxus服务端从不检查;没有 CSRF 检查。参数校验和鉴权只能在函数体里做(server-fn-public-endpoint)。 - 编码有限制:参数按路由字符串分到路径、query 和 JSON body,和 HTTP 方法无关,GET 带 body 参数在浏览器里直接失败;catch-all query 配基本类型在客户端 panic(server-fn-args)。
- 状态码有限制:参数解析失败是 500;
dioxus::Result/ anyhow 的状态码固定 500;超过 2 MiB 的 body 回 500 而不是 413;debug 构建里 panic 的原文发给客户端(server-fn-status-code、server-fn-body-limit、server-fn-error-leak)。 - 用户 crate 必须声明并打开自己的
serverfeature:没声明是 unexpected cfg 编译错误,声明了没打开则静默不登记、POST 回 405(server-fn-feature)。 - 两条调用路径看到的环境不同:经 HTTP 时函数体拿到的是这次 POST,SSR 时是页面的 GET;全局 signal 只在 SSR 那条路径上读得到(第 8 条)。
12. 路由是枚举
选择。 #[derive(Routable)] 在编译期生成「字符串 → 枚举」「枚举 → 字符串」「枚举 → 第几层组件」三段代码,运行时没有路由表;当前路由不缓存,每次从渲染器提供的 History 取字符串再解析(Routable、Router 运行时)。
换来。 用枚举值导航时,目标在编译期就是某个变体(Rust 枚举本身的性质);导航时只有读过路由的作用域重跑,没读路由的 layout 不动;SSR 解析失败直接得到 404。
付出。
- 写回和解析不对称:转义集不含
/、%、&,带这些字符的动态段和查询值Display之后再解析会变样。 - 用 catch-all 路由做 404 页面,状态码是 200,要自己
commit_http_status;内置的 404 正文是纯文本,列出全部路由的匹配过程(route-not-found-status)。 Link的字符串目标解析不了就当外链整页跳转,普通a { href }也整页刷新;带尾斜杠的地址水合后丢掉 active 状态(link-full-reload)。
13. 热重载只在 debug 构建里
选择。 热重载和 hot-patch 的接口都包在 debug_assertions 里:rsx! 的模板从一个按文件、行、列索引的 GlobalSignal 里读,没被替换过才用运行期构造的原始模板;VirtualDom::new 调 register_subsecond_handler,补丁到达后发 SchedulerMsg::AllDirty 让所有组件重跑;use_hook 在有 subsecond 跳转表时用新值覆盖类型不同的 hook;web 主循环连 /_dioxus 的 WebSocket(怎么读这一卷、rsx! 展开成什么、调度)。
换来。 dx serve 改了 rsx! 的静态部分或打了补丁,不重启进程、不丢状态;release 构建里这些代码不存在,release 的 wasm 里连 _dioxus 这个字符串都没有。
付出。 debug 和 release 是两种程序,在 debug 下测过不等于 release 也一样:
- 模板:debug 下运行期构造、路径表泄漏、按值比较,每个
{}洞多格式化一次;release 是static Template、按指针比(Template 与 VNode)。 - 检查:重复 key 只在 debug 断言,release 可能正常也可能 panic
invalid key;Link外面没有Router时 debug panic、release 只打日志渲染成空(带 key 的列表、Router 运行时)。 - 错误信息:generational-box 的错误只在 debug 带位置;水合调试数组带着构建机的绝对路径;server function 的 panic 原文只在 debug 发给客户端(generational-box、水合、server function)。
- 热补丁改了全局 signal 的类型,0.7.3 起静默丢掉旧值重建(#4809),之前是 panic(context 与全局 signal)。
本卷的实验凡是两种构建行为不同,都两种都跑;上线前的验证也应该用 release 构建。
问: 上面这些「付出」都是设计决定的吗?有没有只是 0.7.10 没做好的地方?
答: 分两类。大多数是设计本身带来的,换个版本也还在:运行期的借用冲突和「值已回收」,hook 和水合数据按位置对齐,server function 是公开端点,每个请求重跑整棵树,单线程池里的阻塞。这一类只能在写法上绕开,09 常见坑里每一篇都给了修法。另一类是本卷在 0.7.10 上实测到、和设计没有必然联系的问题:RemoveEventListener 先删 data-dioxus-id(事件委托),增量缓存的内存条目不过期(增量渲染与配置),release 下重复 key 只报 slab 的 invalid key(带 key 的列表),eval 以 return 结束时通道不关闭(document 与 eval),cancel() 之后 suspend()? 直接 panic(异步)。这一类要记住版本,升级时再核对。
小结
- 渲染模型是「整组件重跑 + 只比模板的动态部分」:换来普通控制流和多渲染器,代价是每次更新都跑整个函数,
for生成的项逐个比,if/else换分支整块替换;闭包和children让子组件跟着父组件每次重跑。 - 状态是
Copy的 signal,读时自动订阅:不用写依赖数组、不用clone,代价是借用冲突和值已回收都在运行期,渲染时先读后写会无限重跑且 0.7 没有警告,哪里的读算数要记住规则。 - hook、无 key 的列表、水合数据都按位置认身份:实现简单,顺序一变就是 panic 或静默串值,水合还要求两份编译产物顺序一致。
- 运行时是单线程的:服务端每个请求钉在本地线程池的一个线程上新建
VirtualDom,请求之间互不可见;渲染里阻塞会让分到同一线程的页面请求和 server function 一起等,本页实验两者都多等了约 0.39 秒,静态文件不受影响。 - 默认不流式,首字节前状态码和内容都定了,代价是等最慢的数据;server function 是公开的 HTTP 端点,鉴权、编码、状态码都有自己的限制。
- 路由是编译期生成的枚举代码,404、整页刷新、往返转义是它的边角;热重载只在 debug 构建里,debug 和 release 在模板、检查、错误信息上都不同。