写一个迷你虚拟 DOM
主线位置:把更新主线中间那一段自己走一遍。
count.set(1)把订阅了它的组件标脏 → 队列按高度从根往下取 → 在组件自己的作用域里重跑组件函数、重新订阅 → 新旧VNode先比模板指针、再逐个比动态槽位 → 发出SetText/SetAttribute/ReplaceWith。首屏主线只取一步:SSR 不看指令,沿VNode树直接写 HTML。事件、水合、suspense 不实现。
flowchart TB
write1["count.set 写入"] --> mark["订阅者进 dirty"]
mark --> queue["按高度排序的队列"]
queue --> rerun["run_in_scope 重跑组件"]
rerun --> newv["新 VNode"]
newv --> same{"模板指针相同"}
same -->|"是"| slots["逐个比动态槽位"]
same -->|"否"| repl["新建再 ReplaceWith"]
slots --> muts["Mutation 列表"]
repl --> muts
muts --> interp["解释器执行"]
newv --> ssr["SSR 写 HTML"]
本页只讲思路和关键代码。mini 是一个没有任何依赖的库;另有一个对照程序 mini-vdom-cmp,把同一组组件分别交给 mini 和 dioxus 0.7.10,逐行打印两边的指令。只有对照程序依赖 dioxus:
[dependencies]
dioxus = { version = "=0.7.10", features = ["router", "fullstack"] }
dioxus-core = "=0.7.10"
dioxus-ssr = "=0.7.10"
mini-vdom = { path = "../mini-vdom" }
平台是 M2 Max、macOS 27、rustc 1.99.0。
范围
| 文件 | 做什么 | 代码行(不含空行和注释) |
|---|---|---|
template.rs | Template、TNode、TAttr:静态树和路径表 | 28 |
vnode.rs | VNode、Dyn、Attr、VComponent、挂载信息、类型擦除的 props | 99 |
signal.rs | 线程局部的 runtime、Signal、use_hook、use_signal | 132 |
dom.rs | 组件树、创建、diff、移除、ElementId 分配、重渲染队列 | 276 |
mutations.rs | Mutation 枚举(16 行)和打印格式(27 行) | 43 |
ssr.rs | 沿 VNode 树写 HTML | 56 |
interp.rs | 照 dioxus 的解释器执行指令,用来验证指令序列 | 167 |
demo.rs | 测试和对照程序共用的组件 | 106 |
做事的是前四个文件加 Mutation 枚举,约 550 行;整个库 921 行(含空行和注释 1119 行)。另有 9 个测试。
| 实现了 | 没实现(本页不涉及) |
|---|---|
| 模板克隆、动态属性、动态文本、占位、Fragment、组件 | 带 key 的列表、InsertAfter / InsertBefore / PushRoot |
| 同模板只比动态槽位,不同模板整块替换 | 事件和监听器(NewEventListener、handle_event) |
| 组件按 props 相等跳过重跑 | 水合、suspense、错误边界 |
| signal 读时订阅、写时标脏,重跑前退订 | memo、effect、异步任务 |
按 (高度, ScopeId) 排序的重渲染队列 | signal 和 hook 的回收(generational-box) |
ElementId 后进先出地复用 | rsx! 宏、热重载 |
Template:手写的静态树
dioxus 的 Template 是三个 &'static 切片,由 rsx! 在编译期生成(rsx! 展开成什么)。mini 照抄这个形状,模板手写:
pub struct Template {
pub roots: &'static [TNode],
/// 第 i 个动态节点的路径:第一个字节是根的下标,之后每级一个子节点下标。
/// 必须按深度优先的顺序编号,load_placeholders 倒着填时靠这个顺序。
pub node_paths: &'static [&'static [u8]],
pub attr_paths: &'static [&'static [u8]],
}
pub enum TNode {
Element { tag: &'static str, attrs: &'static [TAttr], children: &'static [TNode] },
Text(&'static str),
Dynamic(usize),
}
pub enum TAttr {
Static(&'static str, &'static str),
Dynamic(usize),
}
和 dioxus 的 TemplateNode 一样只有三种节点,元素标签永远是静态的。去掉的是 namespace(SVG 之类)。
对照程序用的计数器,模板旁边写着它对应的 rsx!:
// div { class: "box", id: "c{count}",
// h1 { "Counter" }
// p { "count = {count}" }
// button { title: "next {next}", "+1" }
// Child { n: count() }
// }
pub static COUNTER: Template = Template {
roots: &[TNode::Element {
tag: "div",
attrs: &[TAttr::Static("class", "box"), TAttr::Dynamic(0)],
children: &[
TNode::Element { tag: "h1", attrs: &[], children: &[TNode::Text("Counter")] },
TNode::Element { tag: "p", attrs: &[], children: &[TNode::Dynamic(0)] },
TNode::Element { tag: "button", attrs: &[TAttr::Dynamic(1)], children: &[TNode::Text("+1")] },
TNode::Dynamic(1),
],
}],
node_paths: &[&[0, 1, 0], &[0, 3]],
attr_paths: &[&[0], &[0, 2]],
};
路径和 Mutations 里那个计数器的模板一样:0 号动态节点是 p 里的文本 [0, 1, 0],1 号是组件 [0, 3];0 号动态属性在根上 [0],1 号在 button 上 [0, 2]。手写路径容易错,测试 templates_are_consistent 沿树走一遍,重新算出每个 Dynamic 的路径,和手写的比对,并检查编号是深度优先的。
每个模板是一个 static,地址唯一。所以「是不是同一个模板」在 mini 里就是 std::ptr::eq(old.template, new.template)。dioxus release 构建里比较三个切片的指针,debug 构建按值比较(原因在 Template 与 VNode)。
VNode:模板指针加动态值
pub struct VNode {
pub template: &'static Template,
pub dyn_nodes: Vec<Dyn>,
pub dyn_attrs: Vec<Attr>,
pub(crate) mount: Mount,
}
/// 挂到渲染器上之后才有的信息
#[derive(Default)]
pub(crate) struct Mount {
pub(crate) root_ids: Vec<ElementId>,
pub(crate) attr_ids: Vec<ElementId>,
/// 文本和占位存 ElementId,组件存 ScopeId,Fragment 不用
pub(crate) node_ids: Vec<usize>,
}
pub enum Dyn {
Text(String),
Placeholder,
/// 非空;Dyn::fragment 把空列表变成 Placeholder
Fragment(Vec<VNode>),
Component(VComponent),
}
/// 动态属性:None 表示删掉这个属性
pub struct Attr {
pub name: &'static str,
pub value: Option<String>,
}
Dyn 的四种和 dioxus 的 DynamicNode 一一对应,空列表变占位也照 dioxus。Mount 三个字段对应 VNodeMount 的 root_ids、mounted_attributes、mounted_dynamic_nodes,node_ids 同样一格两用。差别在放哪:dioxus 的 VNode 是 Rc<VNodeInner> 加一个 Cell<MountId>,挂载信息在 runtime 的 slab 里,因为事件冒泡要从 ElementId 找回挂载信息和 VNode(diff)。mini 没有事件,挂载信息直接放在 VNode 里,diff 时从旧节点 mem::take 给新节点。
组件是渲染函数加类型擦除的 props,形状照 dioxus 的 VProps:
pub struct VComponent {
pub name: &'static str,
/// 组件函数的地址,不同就整个替换
pub(crate) render_fn: usize,
pub(crate) props: Box<dyn AnyProps>,
}
impl<P: PartialEq + Clone + 'static> AnyProps for VProps<P> {
fn render(&self) -> VNode {
(self.render_fn)(&self.props)
}
fn memoize(&mut self, new: &dyn Any) -> bool {
match new.downcast_ref::<P>() {
Some(new) if *new == self.props => true,
Some(new) => {
self.props = new.clone();
false
}
None => false,
}
}
// props()、duplicate() 略
}
memoize 的语义和 #[derive(Props)] 生成的一样(没有 signal 字段的情况):相等就返回 true,不等就把新值存进旧 props 再返回 false(#[component] 与 Props)。
组件函数就是普通函数,没有宏,rsx! 的展开由手写的 VNode::new 代替:
pub fn counter() -> VNode {
let count = use_signal(|| 0_i64);
COUNT.set(Some(count)); // 交给测试和对照程序从外面写
let n = count.get();
log(format!("Counter runs n={n}"));
VNode::new(
&COUNTER,
vec![Dyn::text(format!("count = {n}")), Dyn::component("Child", child, n)],
vec![Attr::new("id", Some(format!("c{n}"))), Attr::new("title", Some(format!("next {}", n + 1)))],
)
}
fn child(n: &i64) -> VNode {
log(format!("Child runs n={n}"));
let branch = if n % 2 == 0 { VNode::new(&EVEN, vec![], vec![]) } else { VNode::new(&ODD, vec![], vec![]) };
VNode::new(&CHILD, vec![Dyn::fragment(vec![branch])], vec![])
}
Child 的 rsx! 是一个 if / else,展开后整个模板只有一个动态根,槽位里是一项的 Fragment;两个分支各是一个模板(EVEN 是 span { "even" },ODD 是静态文本 "odd")。这和 dioxus 的展开一致(diff 的 if / else 实验)。
创建:先写属性,再倒着填
create 遍历模板的根,返回压栈的节点数。动态根交给 create_dyn;静态根分三步,顺序照 dioxus 的 VNode::create:
fn create(&mut self, node: &mut VNode, scope: ScopeId) -> usize {
let t = node.template;
node.mount = Mount {
root_ids: vec![ElementId(0); t.roots.len()],
attr_ids: vec![ElementId(0); t.attr_paths.len()],
node_ids: vec![usize::MAX; t.node_paths.len()],
};
let mut created = 0;
for (root, tnode) in t.roots.iter().enumerate() {
created += match tnode {
TNode::Dynamic(k) => self.create_dyn(&mut node.dyn_nodes[*k], &mut node.mount.node_ids[*k], scope),
TNode::Text(_) | TNode::Element { .. } => {
let id = self.next_element();
node.mount.root_ids[root] = id;
self.edits.push(Mutation::LoadTemplate { template: t, index: root, id });
if let TNode::Element { .. } = tnode {
// 先写属性:这时模板还是原样,路径有效
self.write_attrs(node, root);
// 再填动态节点,倒着填
self.load_placeholders(node, root, scope);
}
1
}
};
}
created
}
write_attrs 和 dioxus 的 write_attrs 一样:属性在根上就用根的 id;在模板深处就分配一个新 id,发 AssignId(路径去掉根的下标);同一个元素上的几个动态属性共用一次 AssignId。然后每个属性发一条 SetAttribute,值是 None 也发。
load_placeholders 对这个根下面的动态节点倒着走:
fn load_placeholders(&mut self, node: &mut VNode, root: usize, scope: ScopeId) {
let paths = node.template.node_paths;
// 倒着走:前面的占位换成多个节点后,后面的路径就错了
for k in Template::nested_under(paths, root).into_iter().rev() {
let m = self.create_dyn(&mut node.dyn_nodes[k], &mut node.mount.node_ids[k], scope);
if m > 0 {
self.edits.push(Mutation::ReplacePlaceholder { path: &paths[k][1..], m });
}
}
}
为什么要倒着走,Mutations 讲过 dioxus 的 load_placeholders 和它丢过一次倒序的历史。在 mini 上可以直接试:测试 placeholders_filled_in_reverse 的模板是 div { {两项的列表} "{text}" },两个动态节点的路径是 [0, 0] 和 [0, 1]。把 .rev() 删掉再跑:
---- placeholders_filled_in_reverse stdout ----
thread 'placeholders_filled_in_reverse' (2150688) panicked at tests/mini.rs:12:3:
assertion `left == right` failed
left: "<div><li>0</li>tail</div>"
right: "<div><li>0</li><li>1</li>tail</div>"
left 是解释器执行指令后的 HTML,right 是 SSR 的结果。正着走时先把 [0, 0] 的占位换成两个 li,原来 [0, 1] 的占位挪到了 [0, 2];文本按旧路径 [0, 1] 去换,换掉的是第二个 li。同一次修改让 counter_rebuild_and_updates 也失败:计数器的两个动态节点创建顺序变了,指令序列和断言不再一致。
create_dyn 处理四种动态节点:文本和占位各分配一个 id、压一个节点;Fragment 把每一项 create 一遍、压栈数相加;组件新建一个 scope,高度是父 scope 加一,跑一次组件函数,再 create 它返回的 VNode。
diff:先比模板指针,再比动态槽位
fn diff(&mut self, old: &mut VNode, new: &mut VNode, scope: ScopeId) {
if !std::ptr::eq(old.template, new.template) {
let m = self.create(new, scope);
self.remove(old, true, Some(m));
return;
}
new.mount = std::mem::take(&mut old.mount);
for (k, (a, b)) in old.dyn_attrs.iter().zip(&new.dyn_attrs).enumerate() {
if a != b {
let id = new.mount.attr_ids[k];
self.edits.push(Mutation::SetAttribute { name: b.name, value: b.value.clone(), id });
}
}
for (k, (a, b)) in old.dyn_nodes.iter_mut().zip(new.dyn_nodes.iter_mut()).enumerate() {
self.diff_dyn(a, b, &mut new.mount.node_ids[k], scope);
}
}
和 dioxus 的 diff_node 是同一个结构:模板不同就先建新的、再让新节点顶替旧的;模板相同就把挂载信息交给新节点,属性和动态节点按下标一一配对。静态部分(class: "box"、h1、"+1")从头到尾不参与比较。少的是 dioxus 的一条快路径 if self == new { return; }:mini 的 VNode 不是 Rc,不会有「同一个节点原样回来」的情况。
每对动态节点:
fn diff_dyn(&mut self, old: &mut Dyn, new: &mut Dyn, slot: &mut usize, scope: ScopeId) {
match (&mut *old, &mut *new) {
(Dyn::Text(a), Dyn::Text(b)) => {
if a != b {
self.edits.push(Mutation::SetText { value: b.clone(), id: ElementId(*slot) });
}
return;
}
(Dyn::Placeholder, Dyn::Placeholder) => return,
(Dyn::Fragment(a), Dyn::Fragment(b)) if a.len() == b.len() => {
for (x, y) in a.iter_mut().zip(b.iter_mut()) {
self.diff(x, y, scope);
}
return;
}
(Dyn::Component(a), Dyn::Component(b)) if a.render_fn == b.render_fn => {
self.diff_component(ScopeId(*slot), b);
return;
}
_ => {}
}
// 种类不同、列表长度不同、组件函数不同:先建新的,再让它顶替旧的
let old_slot = *slot;
let m = self.create_dyn(new, slot, scope);
self.remove_dyn(old, old_slot, true, Some(m));
}
| 旧 → 新 | mini | dioxus(diff_dynamic_node) |
|---|---|---|
| 文本 → 文本 | 不等才 SetText | 相同 |
| 占位 → 占位 | 什么都不做 | 相同 |
| Fragment → Fragment,长度相同 | 按位置逐项 diff | 不带 key 时相同;都带 key 走最长递增子序列 |
| Fragment → Fragment,长度不同 | 整个列表新建,再替换旧列表 | 按位置比公共部分,多出的新建后 InsertAfter,少的 Remove |
| 组件 → 组件,函数相同 | memoize 为真就跳过,否则就地重跑 | 相同(diff_vcomponent) |
| 其余 | 新建,再 ReplaceWith | 相同 |
属性的比较也简化了:mini 的一个槽位只有一个属性,!= 比名字和值;dioxus 的一个槽位是一组属性(给 ..spread 用),按名字归并,监听器永远不算变化,volatile 属性总是重写。
组件那一格:
fn diff_component(&mut self, id: ScopeId, new: &VComponent) {
let Some(scope) = self.scope_mut(id) else { return };
if scope.props.memoize(new.props.props()) {
return;
}
let height = scope.height;
self.run_and_diff(id);
// 它要是也被 signal 标脏了,这次已经顺带跑过
self.dirty.remove(&(height, id));
}
最后一行在下面的队列一节有实验。
问: mini 按 static 的地址认模板。两个分支各写一个内容完全相同的 static,切换时会怎样?
答: 整块替换。两个 static 是两个不同的地址,ptr::eq 为假。测试 identical_statics_are_different_templates 里,SAME_A 和 SAME_B 都是 div { "same" },切换一次得到 LoadTemplate <div> index=0 id=2 和 ReplaceWith id=1 m=1。dioxus 在这里不替换:release 构建里内容相同的模板常量被合并成同一个地址,debug 构建按值比较,Template 与 VNode 的 same_template 实验两种构建都是零条指令。mini 要做到这一点,可以把 ptr::eq 换成按值比较,代价是每次 diff 递归比较整棵 roots。
移除:回收顺序和分配顺序相反
ElementId 的分配照 dioxus 的 slab:先用回收的,回收的后进先出,没有就取下一个新号;0 号是根容器,不回收。
fn next_element(&mut self) -> ElementId {
match self.free_ids.pop() {
Some(id) => ElementId(id),
None => {
self.next_id += 1;
ElementId(self.next_id)
}
}
}
光是后进先出还不够,回收的顺序也要照 dioxus 的 remove_node_inner,下一次分配才会拿到同样的号:
fn remove(&mut self, node: &mut VNode, emit: bool, replace_with: Option<usize>) {
let t = node.template;
// 1. 模板深处带动态属性的元素
let mut last = None;
for (k, path) in t.attr_paths.iter().enumerate() {
let id = node.mount.attr_ids[k];
if path.len() > 1 && last != Some(id) {
self.reclaim(id);
last = Some(id);
}
}
// 2. 模板深处的动态节点:随根一起离开 DOM,不发指令,只回收和销毁
for k in 0..t.node_paths.len() {
if t.node_paths[k].len() > 1 {
self.remove_dyn(&mut node.dyn_nodes[k], node.mount.node_ids[k], false, None);
}
}
// 3. 根:只有根发指令,最后一个根用 ReplaceWith 让栈上的新节点顶替
let last_root = t.roots.len().saturating_sub(1);
for (i, root) in t.roots.iter().enumerate() {
let replace_with = replace_with.filter(|_| i == last_root);
match root {
TNode::Dynamic(k) => {
self.remove_dyn(&mut node.dyn_nodes[*k], node.mount.node_ids[*k], emit, replace_with)
}
TNode::Text(_) | TNode::Element { .. } => self.remove_id(node.mount.root_ids[i], emit, replace_with),
}
}
}
创建时先给根分配、后给内部节点分配;移除时先回收内部、最后回收根。根最后入栈,下一次最先被取出,同样结构的新节点就拿回同样的 id。下面计数器第 2 步的 LoadTemplate <span> index=0 id=3 就是这样来的:第 1 步替换时 span 的 id 3 被回收,第 2 步又分给了新的 span。remove_dyn 遇到组件时,先移除组件上一次渲染的 VNode,再删掉 scope、把它从脏队列里拿掉、退订它的 signal。
signal:读时订阅,写时标脏
状态放在一个线程局部的 runtime 里:
#[derive(Default)]
struct Runtime {
signals: Vec<SignalSlot>,
hooks: HashMap<ScopeId, Vec<Box<dyn Any>>>,
hook_index: usize,
/// 正在运行的组件。dioxus 是 ReactiveContext 栈的栈顶
current: Option<ScopeId>,
/// 被标脏、还没进队列的组件。相当于 dioxus 通道里的 SchedulerMsg::Immediate
dirty: Vec<ScopeId>,
}
struct SignalSlot {
value: Box<dyn Any>,
subscribers: BTreeSet<ScopeId>,
}
thread_local! {
static RT: RefCell<Runtime> = RefCell::new(Runtime::default());
}
Signal<T> 是一个下标加 PhantomData,手写 Clone、Copy、PartialEq(derive 会要求 T 也满足)。读和写:
fn read(&self, subscribe: bool) -> T {
with_rt(|rt| {
let current = rt.current;
let slot = &mut rt.signals[self.id];
if let (true, Some(scope)) = (subscribe, current) {
slot.subscribers.insert(scope);
}
match slot.value.downcast_ref::<T>() {
Some(v) => v.clone(),
None => unreachable!("Signal<T> 只由 Signal::<T>::new 创建,槽位不回收也不换类型"),
}
})
}
/// 写,然后把订阅者全部标脏。和 dioxus 一样不比较新旧值
pub fn update(&self, f: impl FnOnce(&mut T)) {
with_rt(|rt| {
let slot = &mut rt.signals[self.id];
match slot.value.downcast_mut::<T>() {
Some(v) => f(v),
None => unreachable!("Signal<T> 只由 Signal::<T>::new 创建,槽位不回收也不换类型"),
}
let subscribers: Vec<ScopeId> = slot.subscribers.iter().copied().collect();
rt.dirty.extend(subscribers);
});
}
get() 是 read(true),peek() 是 read(false),set(v) 是 update(|x| *x = v)。和 dioxus 的 Signal::try_read_unchecked 比:
- 订阅谁。 dioxus 订阅线程局部栈顶的
ReactiveContext,组件、memo、effect 各有一个;mini 只有组件,订阅的就是ScopeId。 - 读出来的是克隆。 dioxus 的
read()返回一个借用守卫,signal()才是克隆;mini 只有克隆,没有守卫,也就没有AlreadyBorrowed这类冲突(Signal)。 - 什么时候通知。 dioxus 在
WriteLockdrop 时由update_subscribers通知;mini 在update末尾。两边都只是「标脏」,不在写的地方重跑组件。 - 回收。 dioxus 的 signal 存在 generational-box 里,跟着创建它的 scope 回收;mini 的槽位从不回收。
组件函数在 run_in_scope 里跑:
pub(crate) fn run_in_scope<R>(scope: ScopeId, f: impl FnOnce() -> R) -> R {
let saved = with_rt(|rt| {
for slot in &mut rt.signals {
slot.subscribers.remove(&scope);
}
let saved = (rt.current, rt.hook_index);
rt.current = Some(scope);
rt.hook_index = 0;
saved
});
let out = f();
with_rt(|rt| (rt.current, rt.hook_index) = saved);
out
}
先退订、再跑,和 dioxus 的 reset_and_run_in 一样:组件只订阅最近一次渲染读到的 signal。测试 unsubscribe_when_not_read 复现了 Signal 里 sig_subscribe 的现象:flag 为假那一轮没读 count,之后写 count 不再触发重跑。退订的办法不同:dioxus 双向记录,context 记着自己在哪些集合里,退订只碰这些集合;mini 把所有 signal 扫一遍,O(signal 数),小程序够用。
use_hook 按调用序号存取,第一次调用时才执行 init。init 里通常会调 Signal::new,它也要借 runtime,所以 init 必须在借用之外调用:
pub fn use_hook<T: Clone + 'static>(init: impl FnOnce() -> T) -> T {
let (scope, index, existing) = with_rt(|rt| {
let scope = match rt.current {
Some(s) => s,
None => panic!("use_hook 只能在组件里调用"),
};
let index = rt.hook_index;
rt.hook_index += 1;
let existing = rt.hooks.get(&scope).and_then(|h| h.get(index)).map(|b| b.downcast_ref::<T>().cloned());
(scope, index, existing)
});
match existing {
Some(Some(v)) => v,
Some(None) => panic!("第 {index} 个 hook 的类型和第一次渲染时不同:hook 不能放进条件里"),
None => {
// init 里可能再调 Signal::new,所以不能拿着 RT 的借用调它
let v = init();
with_rt(|rt| rt.hooks.entry(scope).or_default().push(Box::new(v.clone())));
v
}
}
}
hook 放进条件里时,类型不同就 panic,和 dioxus 一样只认序号(Scope 与 hooks);测试 hook_type_changes_panics 覆盖这一支。
重渲染队列:从根往下
pub fn render_immediate(&mut self) -> Vec<Mutation> {
for id in signal::take_dirty() {
if let Some(scope) = self.scope(id) {
self.dirty.insert((scope.height, id));
}
}
while let Some((_, id)) = self.dirty.pop_first() {
self.run_and_diff(id);
}
std::mem::take(&mut self.edits)
}
dirty 是 BTreeSet<(u32, ScopeId)>,元组按字典序比较,先比高度、再比 id,和 dioxus 的 ScopeOrder 一样。pop_first 永远取离根最近的那个。dioxus 的 render_immediate 开头同样先把通道里的消息收进有序集合,循环里多一种工作(被唤醒的任务),两者按高度交错(调度);mini 没有任务。
渲染过程中写 signal,消息留在 rt.dirty,这一轮不再收,下一次 render_immediate 才进队列。dioxus 也是这样:process_events 只在开头调一次,循环里只在 poll 完任务后收消息。
为什么要从根往下:父组件重跑可能改掉子组件的 props,也可能把子组件整个删掉。demo::parent 是 调度 里场景 A 的 mini 版:子组件 Kid 订阅自己的 c,父组件订阅 p 并把 p 传给 Kid;先写 c、再写 p,子组件先被标脏。测试 parent_first_child_once 断言父先跑、子只跑一次。把 diff_component 里的 self.dirty.remove(&(height, id)) 注释掉再跑:
---- parent_first_child_once stdout ----
thread 'parent_first_child_once' (2224160) panicked at tests/mini.rs:129:3:
assertion `left == right` failed
left: ["Parent runs p=1", "Kid runs p=1 c=1", "Kid runs p=1 c=1"]
right: ["Parent runs p=1", "Kid runs p=1 c=1"]
父组件 diff 到 Kid 时 props 变了,当场重跑了它;Kid 却还在队列里,循环又取到它,跑了第二次。第二次跑出的 VNode 和上一次一样,不发指令,所以只看指令是发现不了的。
SSR 和解释器
渲染目标有两个。SSR 沿 VNode 树写 HTML:模板里的静态部分照写,动态属性值为 None 就不写,动态文本转义,占位什么都不写,组件去它的 scope 里取上一次渲染的 VNode。和 dioxus-ssr 一样完全不看指令(dioxus-ssr)。
另一个是指令列表本身。为了确认它真的能把 DOM 建对,interp.rs 照 Mutations 里的栈机器语义实现了一个解释器:一个节点栈、一张 ElementId 表,LoadTemplate 克隆模板(动态位置放一个注释)入栈,ReplacePlaceholder 弹出 m 个、沿路径从栈顶找到注释换掉,ReplaceWith / Remove 按 id 找节点。测试里每一步都执行指令,把结果序列化成 HTML,和 SSR 的输出比较。上面删掉 .rev() 的那次失败就是这样抓到的。
测试结果(cargo test --offline,节选):
running 9 tests
test hook_type_changes_panics - should panic ... ok
test templates_are_consistent ... ok
test memoized_child_does_not_rerun ... ok
test unsubscribe_when_not_read ... ok
test identical_statics_are_different_templates ... ok
test placeholders_filled_in_reverse ... ok
test parent_first_child_once ... ok
test counter_rebuild_and_updates ... ok
test list_length_change_replaces_whole_fragment ... ok
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
跑一下:和 dioxus 0.7.10 对照
对照程序把 mini 的三个组件用 rsx! 再写一遍。dioxus 一侧的指令用 03-core 实验里那个 WriteMutations 记录器 Log 打印(每个方法一行,LoadTemplate 多写出模板根),mini 的 Mutation 实现 Display 时用了同样的格式。两侧都不经过事件,从外面写 signal:dioxus 用 dom.in_runtime(|| ..) 写,再 render_immediate(&mut log);mini 直接 set,再 render_immediate()。
fn counter() -> Element {
let count = use_signal(|| 0_i64);
COUNT.set(Some(count));
log(format!("Counter runs n={count}"));
let next = count() + 1;
rsx! {
div { class: "box", id: "c{count}",
h1 { "Counter" }
p { "count = {count}" }
button { title: "next {next}", "+1" }
Child { n: count() }
}
}
}
#[component]
fn Child(n: i64) -> Element {
log(format!("Child runs n={n}"));
rsx! {
if n % 2 == 0 {
span { "even" }
} else {
"odd"
}
}
}
fn parent() -> Element {
let p = use_signal(|| 0_i64);
let c = use_signal(|| 0_i64);
HANDLES.set(Some((p, c)));
let pv = p();
log(format!("Parent runs p={pv}"));
rsx! {
h1 { "p={pv}" }
Kid { p: pv, c }
}
}
#[component]
fn Kid(p: i64, c: Signal<i64>) -> Element {
let cv = c();
log(format!("Kid runs p={p} c={cv}"));
rsx! { "child {p} {cv}" }
}
fn list() -> Element {
let n = use_signal(|| 2_i64);
LEN.set(Some(n));
rsx! {
ul {
for i in 0..n() {
li { "{i}" }
}
}
}
}
每一步打印两列指令(左 dioxus,右 mini,不同的行前面标 ≠)、两边的组件运行记录、dioxus_ssr::render 的 HTML;mini 的 SSR 和「解释器执行 mini 指令」的结果都和它相同时只打一行 same。debug 构建的输出:
debug_assertions = true
===== 1 counter
--- rebuild: dioxus 9 edits, mini 9 edits
LoadTemplate <div> index=0 id=1 | LoadTemplate <div> index=0 id=1
SetAttribute id=Text("c0") id=1 | SetAttribute id=Text("c0") id=1
AssignId path=[2] id=2 | AssignId path=[2] id=2
SetAttribute title=Text("next 1") id=2 | SetAttribute title=Text("next 1") id=2
LoadTemplate <span> index=0 id=3 | LoadTemplate <span> index=0 id=3
ReplacePlaceholder path=[3] m=1 | ReplacePlaceholder path=[3] m=1
CreateTextNode "count = 0" id=4 | CreateTextNode "count = 0" id=4
ReplacePlaceholder path=[1, 0] m=1 | ReplacePlaceholder path=[1, 0] m=1
AppendChildren id=0 m=1 | AppendChildren id=0 m=1
runs:
Counter runs n=0 | Counter runs n=0
Child runs n=0 | Child runs n=0
dioxus ssr: <div class="box" id="c0"><h1>Counter</h1><p>count = 0</p><button title="next 1">+1</button><span>even</span></div>
mini ssr / mini edits applied: same
--- count = 1: dioxus 5 edits, mini 5 edits
SetAttribute id=Text("c1") id=1 | SetAttribute id=Text("c1") id=1
SetAttribute title=Text("next 2") id=2 | SetAttribute title=Text("next 2") id=2
SetText "count = 1" id=4 | SetText "count = 1" id=4
LoadTemplate "odd" index=0 id=5 | LoadTemplate "odd" index=0 id=5
ReplaceWith id=3 m=1 | ReplaceWith id=3 m=1
runs:
Counter runs n=1 | Counter runs n=1
Child runs n=1 | Child runs n=1
dioxus ssr: <div class="box" id="c1"><h1>Counter</h1><p>count = 1</p><button title="next 2">+1</button>odd</div>
mini ssr / mini edits applied: same
--- count = 2: dioxus 5 edits, mini 5 edits
SetAttribute id=Text("c2") id=1 | SetAttribute id=Text("c2") id=1
SetAttribute title=Text("next 3") id=2 | SetAttribute title=Text("next 3") id=2
SetText "count = 2" id=4 | SetText "count = 2" id=4
LoadTemplate <span> index=0 id=3 | LoadTemplate <span> index=0 id=3
ReplaceWith id=5 m=1 | ReplaceWith id=5 m=1
runs:
Counter runs n=2 | Counter runs n=2
Child runs n=2 | Child runs n=2
dioxus ssr: <div class="box" id="c2"><h1>Counter</h1><p>count = 2</p><button title="next 3">+1</button><span>even</span></div>
mini ssr / mini edits applied: same
...
===== 2 child dirty first, then parent
--- rebuild: dioxus 5 edits, mini 5 edits
LoadTemplate <h1> index=0 id=1 | LoadTemplate <h1> index=0 id=1
CreateTextNode "p=0" id=2 | CreateTextNode "p=0" id=2
ReplacePlaceholder path=[0] m=1 | ReplacePlaceholder path=[0] m=1
CreateTextNode "child 0 0" id=3 | CreateTextNode "child 0 0" id=3
AppendChildren id=0 m=2 | AppendChildren id=0 m=2
runs:
Parent runs p=0 | Parent runs p=0
Kid runs p=0 c=0 | Kid runs p=0 c=0
dioxus ssr: <h1>p=0</h1>child 0 0
mini ssr / mini edits applied: same
--- c = 1, then p = 1: dioxus 2 edits, mini 2 edits
SetText "p=1" id=2 | SetText "p=1" id=2
SetText "child 1 1" id=3 | SetText "child 1 1" id=3
runs:
Parent runs p=1 | Parent runs p=1
Kid runs p=1 c=1 | Kid runs p=1 c=1
dioxus ssr: <h1>p=1</h1>child 1 1
mini ssr / mini edits applied: same
===== 3 list length 2 -> 3 -> 1
--- rebuild: dioxus 9 edits, mini 9 edits
LoadTemplate <ul> index=0 id=1 | LoadTemplate <ul> index=0 id=1
LoadTemplate <li> index=0 id=2 | LoadTemplate <li> index=0 id=2
CreateTextNode "0" id=3 | CreateTextNode "0" id=3
ReplacePlaceholder path=[0] m=1 | ReplacePlaceholder path=[0] m=1
LoadTemplate <li> index=0 id=4 | LoadTemplate <li> index=0 id=4
CreateTextNode "1" id=5 | CreateTextNode "1" id=5
ReplacePlaceholder path=[0] m=1 | ReplacePlaceholder path=[0] m=1
ReplacePlaceholder path=[0] m=2 | ReplacePlaceholder path=[0] m=2
AppendChildren id=0 m=1 | AppendChildren id=0 m=1
dioxus ssr: <ul><li>0</li><li>1</li></ul>
mini ssr / mini edits applied: same
--- n = 3: dioxus 4 edits, mini 11 edits
LoadTemplate <li> index=0 id=6 | LoadTemplate <li> index=0 id=6
≠ CreateTextNode "2" id=7 | CreateTextNode "0" id=7
ReplacePlaceholder path=[0] m=1 | ReplacePlaceholder path=[0] m=1
≠ InsertAfter id=4 m=1 | LoadTemplate <li> index=0 id=8
≠ | CreateTextNode "1" id=9
≠ | ReplacePlaceholder path=[0] m=1
≠ | LoadTemplate <li> index=0 id=10
≠ | CreateTextNode "2" id=11
≠ | ReplacePlaceholder path=[0] m=1
≠ | Remove id=2
≠ | ReplaceWith id=4 m=3
dioxus ssr: <ul><li>0</li><li>1</li><li>2</li></ul>
mini ssr / mini edits applied: same
--- n = 1: dioxus 2 edits, mini 6 edits
≠ Remove id=6 | LoadTemplate <li> index=0 id=4
≠ Remove id=4 | CreateTextNode "0" id=5
≠ | ReplacePlaceholder path=[0] m=1
≠ | Remove id=6
≠ | Remove id=8
≠ | ReplaceWith id=10 m=1
dioxus ssr: <ul><li>0</li></ul>
mini ssr / mini edits applied: same
(... 处删掉的一步留给下面的动脑筋。)--release 再跑一次,除了第一行 debug_assertions = false,输出逐字相同:dioxus 的模板在 debug 下按值比、release 下按指针比,在这几个例子里结论一样。
set(2) 写一次同样的值。两边各发几条指令?Counter 和 Child 各跑几次?对照程序在场景 1 的第 2 步之后多了一步,两边都是从外面 set(2) 再 render_immediate。平台同上,debug 和 release。答案在页末。
相同的部分
场景 1、2 的每一条指令、每一个 ElementId、组件的运行次数和顺序都相同。这不是巧合,是因为 mini 照抄了 dioxus 所有会影响输出顺序的规则:
- 创建顺序:根 → 属性(必要时
AssignId)→ 倒序的动态节点。所以Child的span(1 号动态节点)在p的文本(0 号)之前创建,拿到 id 3,文本拿到 4。 m的计数:Child自己的模板只有一个动态根,不压栈;压栈的是span那个模板,所以ReplacePlaceholder path=[3] m=1。- 替换的写法:先建新节点,再让最后一个旧根
ReplaceWith。所以第 1 步的"odd"拿到新号 5,而不是刚要释放的 3。 - id 的复用:回收顺序和分配顺序相反、空闲表后进先出。所以第 2 步的
span拿回 3。 - 队列:按高度从根往下,父组件 diff 到子组件时当场重跑它并移出队列。所以场景 2 里
Kid只跑一次,排在Parent后面。
SSR 输出、mini 指令经解释器执行后的 DOM 也都和 dioxus_ssr::render 相同。
不同的部分
只有场景 3,列表长度变了。mini 的 diff_dyn 只在长度相同时逐项比,长度不同就把整个列表当作「种类不同」:新建全部新项,再把旧列表整个移除,最后一项用 ReplaceWith 顶替。dioxus 不带 key 时走 diff_non_keyed_children:
match old.len().cmp(&new.len()) {
Ordering::Greater => self.remove_nodes(to.as_deref_mut(), &old[new.len()..], None),
Ordering::Less => self.create_and_insert_after(
to.as_deref_mut(),
&new[old.len()..],
old.last().unwrap(),
parent,
),
Ordering::Equal => {}
}
for (new, old) in new.iter().zip(old.iter()) {
old.diff_node(new, self, to.as_deref_mut());
}
逐行看两次更新:
- 2 → 3,dioxus 4 条:只建多出来的
li 2(id 6、文本 7),InsertAfter id=4插到旧列表最后一项后面;前两项按位置 diff,文本没变,不发指令。 - 2 → 3,mini 11 条:三个
li全部新建(id 6 到 11,空闲表是空的,只能取新号),Remove id=2删掉旧的第一项,ReplaceWith id=4 m=3用栈上的三个新li顶替旧的第二项。第一行相同只是因为两边新建的第一个li都拿到了下一个新号 6,它的文本已经不同。 - 3 → 1,dioxus 2 条:公共的第一项按位置 diff,没有变化;多出的两项由
remove_nodes倒着删(nodes.iter().rev()),所以先Remove id=6再Remove id=4。 - 3 → 1,mini 6 条:新建一个
li 0。上一步移除旧列表时回收的顺序是 3、2、5、4(每项先回收内部文本、再回收根),后进先出,新li拿到 4、文本拿到 5;然后删掉三个旧项,最后一项用ReplaceWith。
两边最后的 DOM 相同,差的是 DOM 操作的数量和节点的身份:dioxus 保留了 li 0、li 1 这两个 DOM 节点,mini 每次都换成新的。如果这些 li 里有组件,mini 会销毁并重建它们的 scope,hook 里的状态全部丢失。按位置增删只要在 diff_dyn 的 Fragment 分支里加上这两种情况;带 key 时还要做匹配和最长递增子序列,见 带 key 的列表。
输出看不出来的不同
| mini | dioxus | 影响 |
|---|---|---|
模板按 static 地址比 | release 按三个切片的指针比,debug 按值比 | 内容相同的两个模板,mini 整块替换(上面的没有蠢问题) |
挂载信息在 VNode 里 | 在 runtime 的 slab 里,VNode 只存 MountId | dioxus 能从 ElementId 找回 VNode,事件冒泡靠它 |
| 一个属性槽位一个属性,值相等就不写 | 一组属性按名字归并;监听器不算变化;volatile 属性总是重写 | 放进浏览器时,用户改过的 input 的 value,mini 不会写回 |
| signal 槽位从不回收 | generational-box,跟着 scope 回收 | mini 每卸载一个组件就漏掉它的 signal |
| 退订扫描所有 signal | 双向记录,只碰订阅过的集合 | 规模大时 mini 每次重跑都是 O(signal 数) |
| 队列里只有组件 | 组件和任务按高度交错,effect 最后跑 | mini 没有异步 |
| 组件函数 panic 就 panic | run_scope 用 catch_unwind 接住,交给错误边界 | — |
动脑筋答案
对照程序场景 1 被删掉的那一步,debug 和 release 相同:
--- count = 2 again: dioxus 0 edits, mini 0 edits
runs:
Counter runs n=2 | Counter runs n=2
dioxus ssr: <div class="box" id="c2"><h1>Counter</h1><p>count = 2</p><button title="next 3">+1</button><span>even</span></div>
mini ssr / mini edits applied: same
- 两边都是 0 条指令。
Counter跑了 1 次:写入不比较新旧值,订阅者照样被标脏,组件照样重跑(Signal)。mini 的update也是这样写的。Child跑了 0 次:Counter重跑后 diff 到Child,props 还是n: 2,memoize返回真,子组件不跑。Counter自己的动态属性和文本也都和上一次相等,所以一条指令都没有。重跑是有代价的,只是代价在 Rust 这一侧,没有传到渲染器。
小结
- 一个虚拟 DOM 的骨架不大:静态树加路径表的
Template,模板指针加动态值的VNode,先比模板指针、再按下标比动态槽位的 diff,一个线程局部的 runtime 记住「谁在运行」和「谁被写脏了」。mini 的核心约 550 行,没有依赖。 - 创建的顺序决定了指令的形状:根先入栈,先写属性(路径按模板原样算),再倒着填动态节点。删掉
.rev(),相邻的列表和文本就会填错位置。 ElementId后进先出地复用,回收顺序和分配顺序相反,同样的结构拿回同样的 id。- signal 读时把当前组件加进订阅者,写时把订阅者标脏;组件每次重跑前先退订。队列按
(高度, ScopeId)排序,父组件当场重跑子组件后要把它移出队列,否则子组件跑两次。 - 计数器和父子组件两组例子,mini 的指令、
ElementId、组件运行次数和 dioxus 0.7.10 逐行相同,debug 和 release 一样。列表长度变化时 mini 整块重建,dioxus 只增删尾部、保留已有节点。