Rust in Everything
基于 dioxus 0.7.10

设计取舍

主线位置:三条主线的汇总。更新主线上的取舍是「编译期模板加 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;复制出来的句柄冲突,运行期 panic AlreadyBorrowedMut / 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_value panic「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 必须声明并打开自己的 server feature:没声明是 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 构建。

📝NOTE
没有蠢问题

问: 上面这些「付出」都是设计决定的吗?有没有只是 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 在模板、检查、错误信息上都不同。

💬 关联讨论

发起讨论
加载中...