Rust in Everything
基于 dioxus 0.7.10

Mutations

主线位置:更新主线上「diff → WriteMutations → interpreter 批量改 DOM」中间那一格。VirtualDom 不碰 DOM,它在 diff 和创建节点时调用一个 &mut impl WriteMutations 的方法,谁实现这个 trait 谁就是渲染器。首屏主线也经过这里:服务端 rebuild_in_place 传的是 NoOpMutations,浏览器水合时传的是关掉输出的 WebsysDom。

flowchart TB
  diffnode["diff 与 create"] --> wm["WriteMutations"]
  wm --> mvec["Mutations 收进 Vec"]
  wm --> noop["NoOpMutations 全丢掉"]
  wm --> websys["WebsysDom 编码给 JS"]
  websys --> js["interpreter 跑栈机器"]
  js --> dom["真实 DOM"]
  mvec --> tests["测试与实验打印"]

本页讲这个 trait 的每个方法、它们组成的栈机器,以及 dioxus 自带的两个实现。指令由谁、按什么规则产生,在 diff 和 带 key 的列表;浏览器里的实现(sledgehammer 编码、模板缓存成 DOM 再 cloneNode)在 WebsysDom 与 interpreter。

本页实验都在原生 VirtualDom 上跑,不需要浏览器:依赖 dioxus =0.7.10、dioxus-core =0.7.10、dioxus-ssr =0.7.10、dioxus-html =0.7.10(serialize),平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建。

十五个方法

WriteMutations 的文档注释说得很直接:「Mutations are the only link between the RealDOM and the VirtualDOM.」trait 一共十五个方法,没有返回值,也没有默认实现。每个方法在 Mutation 枚举里有一个同名的变体。

渲染器要维护两样东西:一个节点栈,和一张 ElementId → 节点 的表。按对栈的作用,十五个方法分成三组:

方法Mutation 变体栈core 里谁调用
load_template(template, index, id)LoadTemplate { index, id }压入 1 个load_template_root:创建模板的一个静态根
create_text_node(value, id)CreateTextNode压入 1 个create_dynamic_text:动态文本节点
create_placeholder(id)CreatePlaceholder压入 1 个create_placeholder:动态节点是占位(None、出错或挂起的组件)
push_root(id)PushRoot压入 1 个push_all_root_nodes:列表 diff 要移动已有节点
replace_placeholder_with_nodes(path, m)ReplacePlaceholder弹出 m 个load_placeholders:把栈上的 m 个节点放进模板里的空位
append_children(id, m)AppendChildren弹出 m 个只在 rebuild 最后一步,挂到 ElementId(0)
replace_node_with(id, m)ReplaceWith弹出 m 个删除旧节点时用新节点顶替(reclaim_roots、remove_dynamic_node)
insert_nodes_after(id, m) / insert_nodes_before(id, m)InsertAfter / InsertBefore弹出 m 个列表 diff:新建或移动的节点插到某个兄弟旁边
assign_node_id(path, id)AssignId不变,读栈顶assign_static_node_as_dynamic:模板深处带动态属性的元素
set_attribute(name, ns, value, id)SetAttribute不变write_attribute、remove_attribute(值为 AttributeValue::None 表示删除)
set_node_text(value, id)SetText不变diff_vtext
create_event_listener(name, id)NewEventListener不变write_attribute:属性值是监听器
remove_event_listener(name, id)RemoveEventListener不变remove_attribute
remove_node(id)Remove不变reclaim_roots、remove_dynamic_node

第一组只往栈上放节点,第二组从栈上取走 m 个节点放进文档,第三组按 ElementId 或栈顶直接改节点。m 都是「栈顶的 m 个」,按入栈顺序排列。

path 是一串 u8,每个字节是一个子节点序号,从栈顶节点出发往下走:[] 是栈顶本身,[2] 是它的第三个孩子,[1, 0] 是第二个孩子的第一个孩子。注释写明「Templates cannot have more than 255 children per element, hence the use of a single byte.」模板里记录的 node_paths / attr_paths 第一个字节是根的序号,发给渲染器之前要去掉:assign_static_node_as_dynamic 发的是 &path[1..],因为那时栈顶就是这个根。

浏览器端的解释器就是照这个约定写的。interpreter/src/unified_bindings.rs 里 replace_placeholder 的 JS 只有一行:

let els = this.stack.splice(this.stack.length - $n$); let node = this.loadChild($ptr$, $len$); node.replaceWith(...els);

先弹出 n 个,再从新的栈顶按路径找到占位节点,换掉。load_template 是 this.templates[$tmpl_id$][$index$].cloneNode(true) 入栈。

💡TIP
打个比方:预制件工地

工厂按图纸做好一批预制件(Template),第一次用到某种预制件时工地照图纸做一个样品存起来,以后每次都照样品复制一份(cloneNode)。吊车把复制出来的构件吊在半空(压栈),工人照施工单「从这块构件往下数第 2 间房、第 1 个插座」(path)找到位置,贴上编号牌(assign_node_id),装上开关和灯(set_attribute、create_event_listener)。预制件上留着空的预埋孔(占位注释),另外现场浇的小件(动态文本、子组件)先吊上去,再塞进孔里(replace_placeholder_with_nodes)。整块装好之后放到地基上(append_children(ElementId(0), m))。以后的施工单只写编号牌,「把 4 号牌那块的字改成 count = 1」。

比方到这里为止:真实的栈上可以同时吊着好几块构件,后到的小件压在上面;而且编号牌会回收再用,拆掉的构件的牌子马上会贴到新构件上。

栈机器从哪来:create 的三步

指令序列的形状由 VNode::create 决定。它遍历模板的每个根:动态根直接交给 create_dynamic_node;静态的元素或文本根,先 load_template_root 压栈,元素根再写属性、填动态子节点:

                    TemplateNode::Text { .. } | TemplateNode::Element { .. } => {
                        if let Some(to) = to.as_deref_mut() {
                            self.load_template_root(mount, root_idx, dom, to);
                        }

                        // If this is an element, load in all of the placeholder or dynamic content under this root element too
                        if matches!(root, TemplateNode::Element { .. }) {
                            // !!VERY IMPORTANT!!
                            // Write out all attributes before we load the children. Loading the children will change paths we rely on
                            // to assign ids to elements with dynamic attributes
                            if let Some(to) = to.as_deref_mut() {
                                self.write_attrs(mount, &mut attrs, root_idx as u8, dom, to);
                            }
                            // This operation relies on the fact that the root node is the top node on the stack so we need to do it here
                            self.load_placeholders(
                                mount,
                                &mut nodes,
                                root_idx as u8,
                                dom,
                                to.as_deref_mut(),
                            );
                        }

                        // This creates one node on the stack
                        1
                    }

三步的顺序都有原因:

  1. load_template_root:dom.next_element() 分配一个新 ElementId,记进挂载信息的 root_ids,发 load_template。
  2. write_attrs:路径是按模板原始形状算的,必须在填动态子节点之前用,否则占位注释被换成 0 个或多个节点后,路径就指错了位置。带动态属性的元素如果就是根,直接用根的 id;否则分配新 id、发 assign_node_id。同一个元素上的几个动态属性共用一次 AssignId(last_path 相同就复用 id)。属性值是监听器时发 create_event_listener(名字去掉 on 前缀),其余发 set_attribute。
  3. load_placeholders:对这个根下的每个动态节点,先创建它(文本、占位、Fragment、组件,可能压入 0 到 n 个节点),再发 replace_placeholder_with_nodes(&path[1..], m) 把它们换进模板里的占位注释。这一步倒着走:
        let reversed_iter = (start..=end).rev();

        for dynamic_node_id in reversed_iter {
            let m = self.create_dynamic_node(
                &self.dynamic_nodes[dynamic_node_id],
                mount,
                dynamic_node_id,
                dom,
                to.as_deref_mut(),
            );
            if let Some(to) = to.as_deref_mut() {
                // If we actually created real new nodes, we need to replace the placeholder for this dynamic node with the new dynamic nodes
                if m > 0 {
                    // The path is one shorter because the top node is the root
                    let path = &self.template.node_paths[dynamic_node_id][1..];
                    to.replace_placeholder_with_nodes(path, m);
                }
            }
        }

动态节点按深度优先排序。把 [0] 处的占位换成两个节点之后,原来 [1] 处的占位就变成了 [2];从后往前填,前面的路径不受影响。

📝NOTE
冷知识:倒着填动态节点的逻辑曾经被意外删掉过

load_placeholders 里那句 .rev() 上方的注释提到了 issue #2809。这个 issue 报告 0.6 alpha 渲染出错:「those should be 3 buttons and content should go underneadth. instead content is placed inside the second button」。修复它的 PR #2841(「Fix side-by-side fragments by walking in reverse」)说明原因:「In #2365 we accidentally removed the logic where dynamic nodes needed to be filled in reverse, causing issues with pairs of dynamic/component/fragments.」也就是重写 suspense 的 #2365(首次出现在 0.6.0-alpha.0)把倒序填充丢了,相邻的两个动态节点里前一个展开成多个节点时,后一个的路径就指到了错误的位置。#2841 恢复了倒序,加了 tests/many_roots.rs,首次出现在 0.6.0-alpha.3,正式版是 0.6.0。现在 load_placeholders 里那段以「!!VERY IMPORTANT!!」开头、写着「Forgetting to do this will cause weird bugs like」的注释,就是这个 PR 加的。

实验:第一次渲染的完整指令

一个计数器,带一个静态属性、一个动态属性、一段动态文本、一个监听器和一个子组件:

fn app() -> Element {
  let mut count = use_signal(|| 0);
  rsx! {
    div { class: "box", id: "c{count}",
      h1 { "Counter" }
      p { "count = {count}" }
      button { onclick: move |_| count += 1, "+1" }
      Child { n: count() }
    }
  }
}

#[component]
fn Child(n: i32) -> Element {
  rsx! {
    if n % 2 == 0 {
      span { "even" }
    } else {
      "odd"
    }
  }
}

先看 app 的模板。dom.get_scope(ScopeId::APP).unwrap().root_node().template 的两组路径:

node_paths: [[0, 1, 0], [0, 3]]
attr_paths: [[0], [0, 2]]

roots 的 {:#?} 输出有 50 多行,内容是:一个 div 根,属性 Static { name: "class", value: "box" } 和 Dynamic { id: 0 };四个孩子依次是 h1(静态文本 Counter)、p(孩子是 Dynamic { id: 0 })、button(属性 Dynamic { id: 1 },静态文本 +1)、Dynamic { id: 1 }。

两个动态节点:0 号是 p 里的文本(路径 [0, 1, 0]),1 号是 Child 组件([0, 3])。两个动态属性:0 号是 div 的 id([0],就是根),1 号是 button 的 onclick([0, 2])。class: "box"、h1 和 "+1" 全在模板里,不会出现在指令中。

dom.rebuild_to_vec() 的结果,逐条用 {:?} 打印:

== rebuild_to_vec: 9 edits
 0 LoadTemplate { index: 0, id: ElementId(1) }
 1 SetAttribute { name: "id", ns: None, value: Text("c0"), id: ElementId(1) }
 2 AssignId { path: [2], id: ElementId(2) }
 3 NewEventListener { name: "click", id: ElementId(2) }
 4 LoadTemplate { index: 0, id: ElementId(3) }
 5 ReplacePlaceholder { path: [3], m: 1 }
 6 CreateTextNode { value: "count = 0", id: ElementId(4) }
 7 ReplacePlaceholder { path: [1, 0], m: 1 }
 8 AppendChildren { id: ElementId(0), m: 1 }
#指令来自说明
0LoadTemplate index 0 → id 1load_template_rootdiv 整棵克隆入栈。Mutation 里不带模板本身,见下一节
1SetAttribute id="c0" on 1write_attrs0 号动态属性在根上,直接用根的 id,不需要 AssignId
2AssignId [2] → 2write_attrs1 号动态属性的路径 [0, 2] 去掉根序号,得 [2]:栈顶 div 的第三个孩子 button
3NewEventListener click on 2write_attributeonclick 去掉 on。同时 runtime.elements[2] 记下它在模板里的位置,事件靠它找回监听器
4LoadTemplate index 0 → id 3Child 里 if 分支的模板倒序:先处理 1 号动态节点(组件)。Child 自己的模板只有一个动态根,不产生节点;真正压栈的是 span { "even" } 的模板
5ReplacePlaceholder [3] m=1load_placeholders弹出 span,换掉栈顶 div 第四个孩子(占位注释)
6CreateTextNode "count = 0" → 4create_dynamic_text0 号动态节点,文本入栈
7ReplacePlaceholder [1, 0] m=1load_placeholders换掉 p 里的占位
8AppendChildren 0, m=1VirtualDom::rebuild栈上只剩 div,挂到根容器

ElementId(0) 不是分配出来的:Runtime::new 里先往 elements 插一个空位,注释是「the root element is always given element ID 0 since it's the container for the entire tree」。VirtualDom::rebuild 的最后一行就是 to.append_children(ElementId(0), m)。

为了确认这串指令真是一台栈机器,实验 crate 里写了一个 MiniDom(src/lib.rs,约 270 行):照 core.ts 的语义实现 WriteMutations,维护节点栈和 id 表,模板第一次出现时记一个编号,最后把根容器的孩子序列化成 HTML,监听器标成 [onclick]。同一个 app 用 dom.rebuild(&mut mini) 跑一遍,每行后面是执行完这条之后的栈深度:

== MiniDom
load_template(t0, 0, 1) (new template)         stack=1
set_attribute(id, Some("c0"), 1)               stack=1
assign_node_id([2], 2)                         stack=1
create_event_listener(click, 2)                stack=1
load_template(t1, 0, 3) (new template)         stack=2
replace_placeholder_with_nodes([3], m=1)       stack=1
create_text_node("count = 0", 4)               stack=2
replace_placeholder_with_nodes([1, 0], m=1)    stack=1
append_children(0, m=1)                        stack=0

MiniDom html: <div class="box" id="c0"><h1>Counter</h1><p>count = 0</p><button [onclick]>+1</button><span>even</span></div>
ssr html:     <div class="box" id="c0"><h1>Counter</h1><p>count = 0</p><button>+1</button><span>even</span></div>

rebuild_in_place then ssr: <div class="box" id="c0"><h1>Counter</h1><p>count = 0</p><button>+1</button><span>even</span></div>

栈最多两层,最后清空;执行结果和 dioxus_ssr::render(&dom) 直接读 VNode 树得到的 HTML 一致(SSR 不输出监听器)。最后一行是另一个 VirtualDom 用 rebuild_in_place()(即 NoOpMutations)建树后的 SSR 输出:一条指令都没执行,HTML 照样能渲染出来,因为 SSR 根本不看指令。

📝NOTE
没有蠢问题

问: 模板里的动态文本为什么不直接写进模板,而是先放一个占位注释,再 CreateTextNode + ReplacePlaceholder?

答: 模板只描述所有实例都一样的部分,渲染器把它缓存一次、以后每个实例都克隆这一份。动态节点每个实例不同,而且数量不固定:一个动态位置可能是一段文本、一个组件、一个列表(0 到 n 个节点)或者什么也没有。模板里只能放一个固定的标记,占位注释就是这个标记;创建时数出实际压栈了几个(m),一次换进去。动态属性就不需要占位,它挂在模板里已有的元素上,按路径找到元素、分配 id 就行。

更新时的指令

点一下按钮(dom.runtime().handle_event("click", .., ElementId(2)),怎么构造事件见 事件),再 wait_for_work + render_immediate(&mut mini),点两次:

before: <div class="box" id="c0"><h1>Counter</h1><p>count = 0</p><button [onclick]>+1</button><span>even</span></div>
-- click #1
set_attribute(id, Some("c1"), 1)               stack=0
set_node_text("count = 1", 4)                  stack=0
load_template(t2, 0, 5) (new template)         stack=1
replace_node_with(3, m=1)                      stack=0
after:  <div class="box" id="c1"><h1>Counter</h1><p>count = 1</p><button [onclick]>+1</button>odd</div>
-- click #2
set_attribute(id, Some("c2"), 1)               stack=0
set_node_text("count = 2", 4)                  stack=0
load_template(t1, 0, 3)                        stack=1
replace_node_with(5, m=1)                      stack=0
after:  <div class="box" id="c2"><h1>Counter</h1><p>count = 2</p><button [onclick]>+1</button><span>even</span></div>
  • 动态属性和动态文本直接按第一次分配的 id 改,不用栈。
  • if 换了分支,模板不同,整块替换:新模板入栈,replace_node_with 换掉旧根。"odd" 也是一个模板(只有一个静态文本根),所以是 load_template 而不是 create_text_node。
  • 第二次换回 span 时用的是 t1,第一次渲染就见过这个模板,MiniDom 不再新建,WebsysDom 同样只在第一次 save_template。id 3 在第一次替换时被回收,第二次又分给了新的 span,复用规则见 diff。

push_root 只在移动已有节点时出现。一个带 key 的 ul,点击时把 [1, 2, 3] 换成 [3, 2, 1]:

== keyed swap [1,2,3] -> [3,2,1]
before: <ul [onclick]><li>1</li><li>2</li><li>3</li></ul>
push_root(6)                                   stack=1
push_root(4)                                   stack=2
insert_nodes_before(2, m=2)                    stack=0
after:  <ul [onclick]><li>3</li><li>2</li><li>1</li></ul>

li 3(id 6)和 li 2(id 4)先后压栈,再一起插到 li 1(id 2)前面。哪些节点要动、为什么是这两个,在 带 key 的列表。

Mutations:收成一个 Vec

Mutations 只有一个字段 pub edits: Vec<Mutation>,它的实现每个方法 push 一个变体。rebuild_to_vec / render_immediate_to_vec 就是 Mutations::default() 加一次 rebuild / render_immediate,core 的测试几乎都靠比较 edits 写断言。有两处要注意:

    fn load_template(&mut self, _template: Template, index: usize, id: ElementId) {
        self.edits.push(Mutation::LoadTemplate { index, id })
    }
            value: match value {
                AttributeValue::Text(s) => AttributeValue::Text(s.clone()),
                AttributeValue::Bool(b) => AttributeValue::Bool(*b),
                AttributeValue::Float(n) => AttributeValue::Float(*n),
                AttributeValue::Int(n) => AttributeValue::Int(*n),
                AttributeValue::None => AttributeValue::None,
                _ => panic!("Cannot serialize attribute value"),
            },
  • LoadTemplate 不带模板,只剩 index 和 id。光有 Vec<Mutation> 没法重放出 DOM,必须另外知道每次加载的是哪个模板。真正的渲染器拿到的是 trait 调用,template 参数在手边:WebsysDom::load_template 用一个以 Template 为键的表给每个模板分配 u16 编号,第一次见到时把它建成 DOM、save_template 到 JS 侧,之后只发编号。
  • 属性值是 AttributeValue::Any(自定义类型)时直接 panic。Listener 不会走到这里:write_attribute 把监听器分给了 create_event_listener。

NoOpMutations:什么也不写

NoOpMutations 是一个空结构体,十五个方法都是空函数体。它的用处是「要把 VirtualDom 建起来,但不需要指令」:

  • VirtualDom::rebuild_in_place 就是 self.rebuild(&mut NoOpMutations)。fullstack 服务端每个请求新建 VirtualDom 后调的就是它(fullstack-server/src/ssr.rs 第 211 行),之后 dioxus_ssr 沿 VNode 树直接写 HTML,见 dioxus-ssr。
  • render_suspense_immediate 在挂起期间重跑 scope 时传的是 None::<&mut NoOpMutations>,这里 NoOpMutations 只用来给 Option 定类型。

core 内部还有一种「不写」:create、diff_node 收的是 Option<&mut impl WriteMutations>,传 None 表示在后台建树(挂起的 suspense 子树就这样建,见 Suspense 与错误边界)。两者不一样:传 NoOpMutations 时 core 认为节点已经渲染出去了,create_scope 会把 scope 标成已挂载(mount),排队的 effect 才能进调度器;传 None 时 render_to.is_some() 为假,scope 不挂载,动态文本和占位干脆不创建(create_dynamic_node 里 Text 和 Placeholder 两支在 to 为 None 时返回 0)。

浏览器水合不用 NoOpMutations。dioxus_web 的启动代码在水合前把 websys_dom.skip_mutations 设成 true,用同一个 WebsysDom 跑 rebuild,跑完再设回 false:

            websys_dom.skip_mutations = true;
                virtual_dom.rebuild(&mut websys_dom);
            });
            websys_dom.skip_mutations = false;

WebsysDom 的每个方法开头都是 if self.skip_mutations() { return; }(没开 hydrate feature 时 skip_mutations() 恒为 false)。服务端送来的 DOM 已经在页面上了,这次 rebuild 只为建出 VirtualDom 内部的树和 ElementId,之后 rehydrate 沿 DOM 把 id 认领到真实节点上,见 水合。

小结

  • WriteMutations 十五个方法,是 VirtualDom 和渲染器之间唯一的接口;渲染器维护一个节点栈和一张 ElementId 表。
  • 压栈:load_template、create_text_node、create_placeholder、push_root;弹出 m 个:replace_placeholder_with_nodes、append_children、replace_node_with、insert_nodes_after/before;其余按 id 或栈顶就地修改。path 从栈顶出发,每级一个 u8。
  • 创建一个模板根:加载模板 → 先写动态属性(必要时 AssignId)→ 倒序创建动态节点并换进占位。倒序是为了不让前面的替换弄乱后面的路径,0.6 alpha 丢过一次(#2809 / #2841)。
  • 实验里 9 条指令建出整个计数器,MiniDom 照 core.ts 执行的结果和 SSR 输出一致;更新只发 SetAttribute / SetText,换分支才整块替换,移动节点用 PushRoot + InsertBefore。
  • Mutations 收成 Vec<Mutation>,丢掉了模板、遇到 Any 属性会 panic,主要给测试用;NoOpMutations 用于 rebuild_in_place(SSR)。浏览器水合用的是 skip_mutations = true 的 WebsysDom。

💬 关联讨论

发起讨论
加载中...