Rust in Everything
基于 dioxus 0.7.10

dioxus 解决什么问题

主线位置:三条主线的由来。本页说明 dioxus 为什么要有一个虚拟 DOM(更新主线的 diff 和 Mutations)、为什么同一棵树既能变成浏览器里的 DOM 指令又能变成服务端的 HTML(首屏主线),以及和 React、Leptos、Sycamore、Yew 相比,它把哪些事放在编译期、哪些放在运行期。

flowchart TB
  code["组件函数里的 rsx!"] --> tpl["编译期 static Template"]
  code --> vnode["运行期 VNode 只带动态值"]
  tpl --> vdom["VirtualDom 只比动态部分"]
  vnode --> vdom
  vdom -->|"WriteMutations"| webr["dioxus-web 改 DOM"]
  vdom -->|"WriteMutations"| vecr["Mutations 收进 Vec"]
  vdom -->|"读整棵树"| ssr["dioxus-ssr 写 HTML"]

dioxus 怎么描述自己

门面 crate 的 Cargo.toml 里 description = "Build fullstack web, desktop, and mobile apps with a single codebase."。仓库 README 的第一段:

Build for web, desktop, and mobile, and more with a single codebase. Zero-config setup,
integrated hot-reloading, and signals-based state management. Add backend functionality
with Server Functions and bundle with our CLI.

dioxus-core 的 README 说得更具体:它「provides a fast and featureful VirtualDom implementation for Rust」,特性列表的头两条是「UI components are just functions」和「State is provided by hooks」。

把这几句拆开,就是本卷三条主线背后的三个选择:

说法落在哪里讲它的部分
single codebase组件只产出与平台无关的 VNode,平台差异全在渲染器里;同一个 crate 用 feature 编成客户端和服务端怎么读这一卷、05 浏览器渲染
VirtualDom、components are just functions组件是返回 Element 的普通函数,状态变了就整个重跑,VirtualDom 比较新旧两棵树03 VirtualDom 内核
signals-based state managementuse_signal 返回的句柄能 Copy,读的时候自动订阅当前组件,写的时候把它标脏04 响应式
Server Functions一个函数签名生成客户端和服务端两半server function

一棵树,几种输出

dioxus-core 本身不知道浏览器存在。它的输出口是 WriteMutations 这个 trait:load_template、create_text_node、set_node_text、append_children……每个渲染器实现一遍。web 上的 WebsysDom 把它们写成 JS 解释器的指令;测试和实验用的 Mutations 把它们收进一个 Vec。服务端渲染走的是另一条路:dioxus_ssr::Renderer::render 直接拿 &VirtualDom,沿着已经建好的树写 HTML,不经过指令。

下面这个组件有一个 h1、一个十项的静态列表和一行动态文本。在原生程序里建一个 VirtualDom,先 rebuild_to_vec() 看首次渲染的指令、dioxus_ssr::render 看 HTML,再写一次 signal、render_immediate_to_vec() 看更新的指令:

static RUNS: AtomicUsize = AtomicUsize::new(0);
thread_local! {
  static COUNT: Cell<Option<Signal<i32>>> = const { Cell::new(None) };
}

fn app() -> Element {
  let count = use_signal(|| 0);
  COUNT.with(|c| c.set(Some(count)));
  RUNS.fetch_add(1, Ordering::SeqCst);
  rsx! {
    div { class: "card",
      h1 { "标题" }
      ul {
        li { "一" } li { "二" } li { "三" } li { "四" } li { "五" }
        li { "六" } li { "七" } li { "八" } li { "九" } li { "十" }
      }
      p { "count = {count}" }
    }
  }
}

fn main() {
  let mut dom = VirtualDom::new(app);
  let first = dom.rebuild_to_vec();
  // 打印 first.edits 和 dioxus_ssr::render(&dom)
  let mut count = COUNT.with(|c| c.get()).expect("app ran");
  dom.in_runtime(|| count += 1);
  let second = dom.render_immediate_to_vec();
  // 打印 second.edits 和 dioxus_ssr::render(&dom)
}

输出(M2 Max、macOS 27、rustc 1.99.0,debug 构建;release 构建的指令完全相同):

rebuild: 4 edits, app ran 1 time(s)
  LoadTemplate { index: 0, id: ElementId(1) }
  CreateTextNode { value: "count = 0", id: ElementId(2) }
  ReplacePlaceholder { path: [2, 0], m: 1 }
  AppendChildren { id: ElementId(0), m: 1 }
ssr: <div class="card"><h1>标题</h1><ul><li>一</li><li>二</li><li>三</li><li>四</li><li>五</li><li>六</li><li>七</li><li>八</li><li>九</li><li>十</li></ul><p>count = 0</p></div>
update: 1 edits, app ran 2 time(s)
  SetText { value: "count = 1", id: ElementId(2) }
ssr: <div class="card"><h1>标题</h1><ul><li>一</li><li>二</li><li>三</li><li>四</li><li>五</li><li>六</li><li>七</li><li>八</li><li>九</li><li>十</li></ul><p>count = 1</p></div>

几件事一眼能看出来:

  • 首次渲染只有 4 条指令。 十四个元素(div、h1、ul、十个 li、p)没有一个是单独创建的:整块静态结构是一个模板,LoadTemplate 一次拿到;唯一的动态部分 "count = {count}" 用 CreateTextNode 建出来,再按路径 [2, 0](div 的第 3 个孩子 p 的第 1 个孩子)替换掉模板里的占位。模板怎么切、路径怎么编号,见 rsx! 展开成什么。
  • 更新只有 1 条指令,但组件函数跑了第二遍。 app ran 2 time(s):signal 一变,整个 app 重新执行,造出一个新的 VNode;diff 发现新旧是同一个模板,只比它的一个动态文本,于是只有一条 SetText。静态的十个 li 不参与比较。
  • 同一个 VirtualDom,两种输出。 指令是给会改 DOM 的渲染器的;HTML 是 dioxus_ssr 读同一棵树得到的。更新之后再渲染一次 HTML,里面就是 count = 1。

这就是 dioxus 的基本形态:组件整个重跑,但比较的范围由编译期的模板限定。后面拿它和别的框架比,比的就是这两半各自的取舍。

和 React 比:同样的组件,不同的状态

dioxus 的 FAQ(notes/FAQ.md)在「Why hooks?」下面说:「Instead, we borrowed hooks from React. JS and Rust share many structural similarities, so if you're comfortable with React, then you'll be plenty comfortable with Dioxus.」组件是函数、状态放在 hook 里、状态变了组件重跑、靠虚拟 DOM 算出最小改动,这几点和 React 一样。上面的实验里 app ran 2 time(s) 就是这种模型。

有两处不一样,都能在 dioxus 的代码里看到:

  1. 静态结构在编译期切出来。 rsx! 不是在运行期构造一整棵元素树,而是在编译期把静态部分生成成一个 static 的 Template(roots、node_paths、attr_paths),运行期的 VNode 只带一个模板引用和动态值。diff 时两个 VNode 用的是同一个模板,就只比动态值。上面的十个 li 在 diff 里不出现,原因就在这里。
  2. 状态是 signal,不是 setState。 use_signal 返回 Signal<T>,它是 Copy 的,可以直接 move 进事件闭包和 async 块;读它的时候,当前的 ReactiveContext(组件、memo、effect 各有一个)自动成为订阅者,写它的时候只把订阅者标脏。所以「哪些组件要重跑」由读写关系决定,不用像 useEffect 那样手写依赖数组。
📝NOTE
冷知识:dioxus 0.5 把整个状态系统换成了 signal

0.4 及以前,dioxus 的组件拿一个带生命周期的 cx: Scope,状态用 use_state / use_ref,和 React 的 useState 很像。PR #1791(标题是「Remove scope, use_state, use_ref, bump allocator and make everything 'static」)把这一套整个换掉,首次发布在 0.5.0(预发布 v0.5.0-alpha.0 已包含)。PR 描述说它「moves Dioxus away from the classic React useState towards a signal-based paradigm introduced in 0.4.3」,灵感来自 SolidJS,以及「an entirely 'static lifetime system borrowed from Leptos」;改的原因是「finally acknowledging the difficulty of managing a lifetime managed state system」。列出的收益包括「No more dependency arrays」「No more requirement to call clone to bring state into closures and async blocks」「Removal of the scope cx object」「Removal of all lifetimes」。同一段也写明保留什么:「Dioxus will still retain: hooks, global context, components, diffing and re-rendering」。现在的 Signal 能 Copy、组件函数没有参数,都来自这次改动;重跑组件、diff 这一半没有动。

Signal 能 Copy 的代价是借用检查挪到了运行期:值放在 generational-box 管理的存储里,读一个已经释放的值、在持有读锁时写,都是运行期错误,见 generational-box。

和 Leptos、Sycamore 比:要不要虚拟 DOM

这一节没有跑 Leptos 和 Sycamore 的代码,只引用 dioxus 自己的说法,并用 dioxus 这一侧的实验对照。

dioxus 的 README 在 0.5 和 0.6 里有一节「Dioxus vs other frameworks」。0.6.0 的版本这样写 Leptos:

Reactivity model: Leptos uses signals to drive both reactivity and rendering, while Dioxus
uses signals just for reactivity. For managing re-renders, Dioxus uses a highly optimized
VirtualDOM to support desktop and mobile architectures. Both Dioxus and Leptos are extremely fast.

notes/FAQ.md 里关于 Sycamore 的一句是「Sycamore and Dominator are more like SolidJS/Svelte, requiring no VDOM but has less naturally-Rusty state management」。

「signals just for reactivity」在上面的实验里看得到:signal 只决定「哪个组件要重跑」,重跑之后改哪些 DOM 由 diff 决定。按 dioxus 的描述,Leptos 和 Sycamore 让 signal 直接驱动 DOM 的更新,不需要重跑组件、也没有新旧两棵树可比。两种做法的分界:

dioxusdioxus 文档对 Leptos / Sycamore 的描述
signal 管什么只管「谁要重跑」同时管响应和渲染
状态变了之后整个组件函数重跑,造新 VNode,diff 动态部分不需要虚拟 DOM
if、for普通的 Rust 控制流,重跑时重新求值(0.5 的 README 说 Leptos 要用它自己的控制流原语,0.6 删掉了这段)
渲染目标WriteMutations 的任何实现:web、desktop、native、liveview;SSR 读树(0.6 的 README 说 Leptos 更专注于 fullstack web)

性能上,dioxus 自己的说法改过两次。0.5.0 的 README 写的是 dioxus 的虚拟 DOM「performs little-to-no actual diffing」并且「actually faster than Leptos」,附一张 js-framework-benchmark 的表;PR #3292(「simplify leptos comparison」,0.6.0 发布)把它改成上面那句「Both Dioxus and Leptos are extremely fast」。到 0.7.0,整节比较被一个提交删掉了,提交信息是「I have no interest in maintaing a comparison section anymore」。本卷不比较速度:没有在同一台机器上跑过两边的基准,不下结论。

dioxus 这一侧能确定的是代价的形状:组件重跑是整函数的,上例里 app 每次都要重新执行,十个 li 不参与 diff 只是因为它们在模板里;如果它们是由 for 循环生成的动态内容,就会进入 diff(见文末的动脑筋)。把这条取舍放到全卷看,见 08 设计取舍。

❗IMPORTANT
动脑筋:把十个静态 li 换成 for i in 0..10 { li { "{i}" } },首次渲染和更新各有几条指令?

只改 ul 里那一行,其余代码不变,同样先 rebuild_to_vec(),写一次 count,再 render_immediate_to_vec()。平台同上。

和 Yew 比

dioxus 的文档对 Yew 的描述都比较旧。notes/FAQ.md 的「What about Yew/Seed/Sycamore/Dominator/Dodrio/Percy?」一节最早写于 2021 年,说「Yew and Seed use an Elm-like pattern and don't support SSR or any alternate rendering platforms」;0.6.0 的 README 说 Yew「initially served as an inspiration for Dioxus」,「Yew is designed exclusively for single-page web apps」。同一个 FAQ 还说 Dodrio「is the spiritual predecessor of Dioxus」。这些是 dioxus 一方在当时的说法,本卷没有核实 Yew 现在的情况,只把它作为 dioxus 自己的定位:不只做单页 web 应用,渲染器可以换,服务端渲染和水合是框架自带的。

还剩什么:fullstack

上面的比较都是客户端的事。dioxus 把服务端也放进同一个 crate:README 的「Truly fullstack applications」一节说它「deeply integrates with axum」。具体是三件事,分别是首屏主线和调用主线:

  • 服务端是一个 axum::Router:dioxus::server::router(app) 挂上 server function、静态文件和一个渲染页面的 fallback,见一次页面请求。
  • 每个请求在服务端新建一个 VirtualDom,渲染成带水合标记的 HTML,浏览器里的 wasm 接管同一棵树,见水合。
  • #[post] / #[get] / #[server] 让一个 async 函数同时是服务端的 HTTP 端点和客户端的调用桩,见 server function。

这三件都依赖同一份代码被编两次(wasm 客户端和服务端二进制),见怎么读这一卷。

动脑筋答案

rebuild: 35 edits, app ran 1 time(s)
  LoadTemplate { index: 0, id: ElementId(1) }
  CreateTextNode { value: "count = 0", id: ElementId(2) }
  ReplacePlaceholder { path: [2, 0], m: 1 }
  LoadTemplate { index: 0, id: ElementId(3) }
  CreateTextNode { value: "0", id: ElementId(4) }
  ReplacePlaceholder { path: [0], m: 1 }
  LoadTemplate { index: 0, id: ElementId(5) }
  CreateTextNode { value: "1", id: ElementId(6) }
  ReplacePlaceholder { path: [0], m: 1 }
  ...
  LoadTemplate { index: 0, id: ElementId(21) }
  CreateTextNode { value: "9", id: ElementId(22) }
  ReplacePlaceholder { path: [0], m: 1 }
  ReplacePlaceholder { path: [1, 0], m: 10 }
  AppendChildren { id: ElementId(0), m: 1 }
update: 1 edits, app ran 2 time(s)
  SetText { value: "count = 1", id: ElementId(2) }

(中间 "2" 到 "8" 的 21 行省略,形状相同。debug 和 release 构建结果一样。)

  • 首次渲染从 4 条变成 35 条。for 循环的循环体 li { "{i}" } 是另一个模板:外层模板里 ul 的内容变成一个动态占位,十个 li 各自 LoadTemplate 一次、CreateTextNode 建文本、ReplacePlaceholder { path: [0] } 填进 li,最后 ReplacePlaceholder { path: [1, 0], m: 10 } 把这十个一起换进 ul 的位置。每项 3 条,十项 30 条,加上原来的 4 条和最后这 1 条,共 35 条。
  • 更新仍然只有 1 条。app 重跑时 for 循环又生成了十个新的 VNode,它们在 diff 里逐个和旧的比过,文本都没变,所以没有指令;变的只有 count 那一行。差别在 diff 的工作量上:静态的 li 根本不进 diff,循环生成的 li 每次都要比一遍。列表的 diff 见带 key 的列表。

小结

  • dioxus 的定位是「一份代码,多个平台」:组件产出与平台无关的 VNode,VirtualDom 通过 WriteMutations 交给渲染器,SSR 直接读树写 HTML。
  • 和 React 一样是组件、hooks、整组件重跑加虚拟 DOM;不同在于 rsx! 在编译期切出 static Template,diff 只比动态部分;状态用能 Copy 的 signal,读时自动订阅,这是 0.5 的 PR #1791 换上的。
  • 实验:十四个元素、一个动态文本,首次渲染 4 条指令,更新 1 条 SetText,组件函数跑了两遍。
  • 和 Leptos、Sycamore 的分界是「signal 只决定谁重跑」还是「signal 直接改 DOM」;速度对比 dioxus 自己改过两次说法,0.7 删掉了整节比较,本卷不比速度。
  • 和 Yew 的比较只有 dioxus 文档里较旧的说法,本卷不核实对方的现状。

💬 关联讨论

发起讨论
加载中...