Rust in Everything
基于 dioxus 0.7.10

Template 与 VNode

主线位置:更新主线里 run_scope 拿到组件返回的 Element,diff 拿新旧两个 VNode 比;首屏主线里 dioxus_ssr 沿同一棵 VNode 树写 HTML。本页讲这些值本身的形状:哪些字段是编译期的、哪些是每次渲染新建的,相等是怎么判断的。它们是怎么被宏生成的在 rsx! 展开成什么。

flowchart TB
  element["Element 即 Result"] --> vnode[VNode]
  element --> rerr[RenderError]
  vnode --> inner["Rc VNodeInner"]
  vnode --> mount["mount: MountId"]
  inner --> tpl["Template 静态"]
  inner --> dn["dynamic_nodes"]
  inner --> da["dynamic_attrs"]
  tpl --> tn[TemplateNode]
  dn --> comp[Component]
  dn --> text[Text]
  dn --> ph[Placeholder]
  dn --> frag["Fragment 即 Vec VNode"]
  frag --> vnode

全部定义在 packages/core/src/nodes.rs(约 1.1k 行)和 render_error.rs。

VNode:一个 Rc 加一个挂载号

VNode 只有两个字段:

#[derive(Debug, Clone)]
pub struct VNode {
    vnode: Rc<VNodeInner>,

    /// The mount information for this template
    pub(crate) mount: Cell<MountId>,
}

内容在 VNodeInner 里,VNode 通过 Deref 直接暴露它的字段:

pub struct VNodeInner {
    pub key: Option<String>,
    pub template: Template,
    pub dynamic_nodes: Box<[DynamicNode]>,
    pub dynamic_attrs: Box<[Box<[Attribute]>]>,
}

(注释已删。)template 是编译期的那张表,后两个是每次渲染新建的填空内容,两个都是定长的 Box<[_]>,长度就是模板里 Dynamic { id } 的个数。

mount 是一个 MountId,即 runtime 里 mounts: RefCell<Slab<VNodeMount>> 的下标,没挂载时是 usize::MAX(MountId::PLACEHOLDER)。真正的挂载信息在 VNodeMount:父元素、模板每个根的 ElementId(root_ids)、每个动态属性挂在哪个元素上(mounted_attributes)、每个动态节点挂在哪个元素或哪个 scope 上(mounted_dynamic_nodes)。VNode 自己只存这个下标,克隆出来的 VNode 拿到的是同一个下标,挂载信息本身不会被复制。diff 时 move_mount_to 把旧节点的 MountId 取出来、放进新节点,再把表项里的 node 换成新节点(事件冒泡时要从表项找回 VNode)。这些怎么用在 diff。

VNode 的 PartialEq 只比指针:

impl PartialEq for VNode {
    fn eq(&self, other: &Self) -> bool {
        Rc::ptr_eq(&self.vnode, &other.vnode)
    }
}

diff_node 开头先比模板,模板相同时再用这个 == 走一条快路径:if self == new { return; },同一个 Rc 回来了就什么都不用比。两次渲染各自新建的 VNode 内容再一样也是不等的,下面的实验会看到;这一点对 children: Element 这类 props 很要紧,在 #[component] 与 Props 里讲。

VNode::placeholder 是一个只有一个动态槽、槽里是 Placeholder 的 VNode,放在 thread_local! 的 OnceCell 里,同一个线程共用一个 Rc。VNode::empty() 返回的就是 Ok(VNode::placeholder())。

Template:三个静态切片

Template 是 Copy 的,只有三个 &'static 切片(serde 属性和注释已删):

#[derive(Debug, Clone, Copy, Eq, PartialOrd, Ord)]
pub struct Template {
    pub roots: StaticTemplateArray,
    pub node_paths: StaticPathArray,
    pub attr_paths: StaticPathArray,
}

roots 是 &'static [TemplateNode],一个模板可以有多个根;两个路径表是 &'static [&'static [u8]],下标就是动态序号,内容就是 上一页 编出来的路径。Template 上的文档注释说它「promises that its pointer is unique, allow Dioxus to use its static description of the UI to skip immediately to the dynamic nodes during diffing」:diff 只要判断出新旧两个 VNode 用的是同一个模板,就可以跳过整棵静态树,只比动态部分。所以「同一个模板」的判断必须又快又准。

PartialEq for Template 和 Hash 都有两种实现,由 static_items_merged 选:

// Are identical static items merged in the current build. Rust doesn't have a cfg(merge_statics) attribute
// so we have to check this manually
#[allow(unpredictable_function_pointer_comparisons)] // This attribute should be removed once MSRV is 1.85 or greater and the below change is made
fn static_items_merged() -> bool {
    fn a() {}
    fn b() {}
    a as fn() == b as fn()
    // std::ptr::fn_addr_eq(a as fn(), b as fn()) <<<<---- This should replace the a as fn() === b as fn() once the MSRV is 1.85 or greater
}

impl PartialEq for Template {
    fn eq(&self, other: &Self) -> bool {
        // If identical static items are merged, we can compare templates by pointer
        if static_items_merged() {
            std::ptr::eq(self.roots as *const _, other.roots as *const _)
                && std::ptr::eq(self.node_paths as *const _, other.node_paths as *const _)
                && std::ptr::eq(self.attr_paths as *const _, other.attr_paths as *const _)
        }
        // Otherwise, we compare by value
        else {
            self.roots == other.roots
                && self.node_paths == other.node_paths
                && self.attr_paths == other.attr_paths
        }
    }
}

a 和 b 是两个函数体完全相同的空函数。优化打开时编译器会把它们合并成同一个地址,a as fn() == b as fn() 为真;不优化时各有各的地址。Rust 没有一个 cfg 能直接问「相同的静态项会不会被合并」,就用这两个函数探一下,结果当作「内容相同的模板会不会是同一个指针」的代理。

为什么 debug 下要改成按值比,跑一下就知道。实验 same_template:一个组件在 if / else 里返回两段内容完全相同的 rsx!,第一次走 else,改一个线程局部变量后重跑走 if:

const N: i32 = 1;

fn app() -> Element {
  if FLAG.with(|f| f.get()) {
    rsx! { div { "{N}" } }
  } else {
    rsx! { div { "{N}" } }
  }
}

fn main() {
  println!("debug_assertions = {}", cfg!(debug_assertions));
  println!("static_items_merged() = {}", static_items_merged()); // 照抄 core 里的那个私有函数
  let mut dom = VirtualDom::new(app);
  dom.rebuild_to_vec();
  let a = app().unwrap();
  FLAG.with(|f| f.set(true));
  let b = app().unwrap();
  println!(
    "ptr equal: roots={} node_paths={} attr_paths={}; Template == : {}",
    std::ptr::eq(a.template.roots, b.template.roots),
    std::ptr::eq(a.template.node_paths, b.template.node_paths),
    std::ptr::eq(a.template.attr_paths, b.template.attr_paths),
    a.template == b.template
  );
  dom.mark_dirty(ScopeId::APP);
  let edits = dom.render_immediate_to_vec();
  println!("{:?}", edits.edits);
}

debug 和 --release 各跑一次:

debug_assertions = true
static_items_merged() = false
ptr equal: roots=true node_paths=false attr_paths=true; Template == : true
[]
---release
debug_assertions = false
static_items_merged() = true
ptr equal: roots=true node_paths=true attr_paths=true; Template == : true
[]

两段 rsx! 是两个模板(两个调用点),但内容相同,切换分支时 Template == 都为真,diff 只比动态文本,一条指令也不发。

  • release 下三个切片的指针全相等:__TEMPLATE_ROOTS 和路径字面量都是内容相同的常量,被合并成了同一份,按指针比就够了,比较是三次指针比较。
  • debug 下 node_paths 的指针不同。原因在 上一页的 debug 展开:debug 下路径表由 HotReloadedTemplate::new 在运行期算出、各自 Box::leak,两个调用点各漏一份。这时如果还按指针比,相同内容会被判成不同模板,diff 会整块替换。attr_paths 为空,空切片泄漏出来的是同一个悬空地址,所以碰巧相等。
  • debug 下按值比要递归比较整棵 roots,比指针慢,但只在开发构建里付这个代价。
📝NOTE
冷知识:模板以前靠名字认,后来靠指针,debug 下又改回按值

0.5 的 Template 有一个 name: &'static str 字段,宏把调用位置写进去,注释要求它「must be unique across your entire program」,比较就比名字。#2799「Fix nested rsx expansion by not using template titles」删了这个字段,说明是「Templates are now hashed by their content's pointers rather than an arbitrary name (which is wrong due to nested expansion)」:宏里再套宏时同一个位置会展开出不同的模板,名字就不唯一了。这一改首次出现在 0.6.0-alpha.3,正式版是 0.6.0。

只按指针比在 debug 下又出了问题。#3630「Hash and compare templates by value in debug mode」的说明是:「In debug mode, rust doesn't merge statics which makes all rsx generate unique template pointers. That causes state to be lost when switching between identical rsx blocks only in debug mode. We generate identical rsx blocks for layouts in the router.」它修的是 #3235(router 的 Link 跳转时整页重载)。修法就是上面的 static_items_merged 和两套 PartialEq / Hash,首次发布于 0.6.3。上面的实验里,0.7.10 的 debug 构建里不同的是运行期泄漏出来的 node_paths,roots 本身倒是同一个地址。

TemplateNode 与 TemplateAttribute

模板树里的节点只有三种,TemplateNode(注释和 serde 属性已删):

pub enum TemplateNode {
    Element {
        tag: StaticStr,
        namespace: Option<StaticStr>,
        attrs: StaticTemplateAttributeArray,
        children: StaticTemplateArray,
    },
    Text {
        text: StaticStr,
    },
    Dynamic {
        id: usize,
    },
}

元素的属性是 TemplateAttribute:Static { name, value, namespace } 三个都是 &'static str,Dynamic { id } 指向 dynamic_attrs 的下标。注意没有「动态元素」:元素标签永远是静态的,动态的只有它的属性和子节点。namespace 用来区分 SVG 之类的元素(dioxus_elements::svg::NAME_SPACE),对属性则表示 style 一类的属性组。

Template::is_completely_dynamic 判断所有根都是 Dynamic,即这个模板没有任何静态结构。

DynamicNode:四种运行期节点

每个 TemplateNode::Dynamic { id } 在 dynamic_nodes[id] 里有一个 DynamicNode:

pub enum DynamicNode {
    Component(VComponent),
    Text(VText),
    Placeholder(VPlaceholder),
    Fragment(Vec<VNode>),
}
变体内容从哪来
ComponentVComponent:name、渲染函数地址 render_fn: usize、类型擦除的 props BoxedAnyPropsrsx! 里的组件,经 into_vcomponent
TextVText 里一个 String带洞的文本、{"..."}、{some_string}
Placeholder空的 VPlaceholderNone、()、空迭代器、Err 的 Element、没有 else 的 if
FragmentVec<VNode>,每个 VNode 有自己的模板for、if 分支、{element}、迭代器

Fragment 是嵌套发生的地方:它装的不是 DynamicNode 而是整个 VNode,所以一个 for 产生的每一项都是一个完整的「模板 + 动态值」。Placeholder 在 DOM 里是一个注释节点,让「什么都不渲染」的位置也有一个可以被替换的锚点(解释器里是 document.createComment('placeholder'));suspense 挂起时也用它。

VComponent::render_fn 是组件函数的地址,diff 时 render_fn 不同就直接替换整个组件、不比 props;相同才去问 props 的 memoize,见 #[component] 与 Props。

Attribute 与 AttributeValue

动态属性是 Attribute:name、value、namespace,外加一个 volatile。volatile 为真表示「每次都要重新设置」,宏从 dioxus_elements::<el>::<attr>.2 取,用在 input 的 value 这类用户输入会直接改掉 DOM 状态的属性上。值是 AttributeValue:

pub enum AttributeValue {
    /// Text attribute
    Text(String),

    /// A float
    Float(f64),

    /// Signed integer
    Int(i64),

    /// Boolean
    Bool(bool),

    /// A listener, like "onclick"
    Listener(ListenerCallback),

    /// An arbitrary value that implements PartialEq and is static
    Any(Rc<dyn AnyValue>),

    /// A "none" value, resulting in the removal of an attribute from the dom
    None,
}

几点:

  • 整数一律收成 Int(i64),包括 u64、u128,IntoAttributeValue 里是 self as _,超出 i64 的值会被截断。
  • Option<T> 的 None 变成 AttributeValue::None,渲染器删掉这个属性;这就是 title: None::<String> 或没有 else 的 if 属性不出现在 HTML 里的原因。
  • 事件监听器也是属性,onclick: move |_| ... 变成 Listener(ListenerCallback)。ListenerCallback 的相等是 Rc::ptr_eq 加来源 scope 相同,每次渲染的新闭包都不相等;diff 遇到两个 Listener 时的处理在 diff。
  • Any 装任意实现了 PartialEq 的 'static 值,相等靠 AnyValue:先 downcast_ref 成同一类型再 ==,类型不同就是不等。

dynamic_attrs 的每一项是 Box<[Attribute]> 而不是一个属性:..attrs 展开会往同一个槽里放任意多个属性,VNodeInner 的注释要求槽内是「static named attributes, remaining dynamically named attributes」的顺序。

Element 就是 Result

Element 只是一个别名:

    pub type Element = std::result::Result<VNode, RenderError>;

RenderError 两个变体:Error(CapturedError)(一个 Arc<anyhow::Error>,能廉价克隆)和 Suspended(SuspendedFuture)。From<E: Into<anyhow::Error>> 让组件里任何错误都能直接 ?;转换时先试着 downcast_ref::<SuspendedFuture>() 和 downcast_ref::<RenderError>(),所以一个挂起即使被包进了 anyhow::Error,转回来也还是 Suspended,不会被当成普通错误。返回 Result 而不是 Option,就是为了让「出错」和「还没好」都能用 ? 从组件里提前返回,由最近的边界接住,见 Suspense 与错误边界。

IntoDynNode:什么能放进 rsx

{expr}、for、if 展开后都调 IntoDynNode。实验 vnode_bits 把常见类型逐个转一遍(hello() 是一个返回 rsx! { "hello" } 的函数):

  let a = hello().unwrap();
  let a2 = a.clone();
  let b = hello().unwrap();
  println!("clone == original: {}; two renders ==: {}; same template: {}", a == a2, a == b, a.template == b.template);
  println!("two placeholders ==: {}", VNode::placeholder() == VNode::placeholder());

  let none: Option<&str> = None;
  let empty = std::iter::empty::<Element>();
  let err: Element = Err(RenderError::default());
  println!("()                -> {}", label(().into_dyn_node()));
  println!("\"text\"            -> {}", label("text".into_dyn_node()));
  // ... String、format_args!、Some("x")、None、VNode、Ok(VNode) 同理
  println!("Err(RenderError)  -> {}", label(err.into_dyn_node()));
  println!("iter of 3 Element -> {}", label((0..3).map(|_| hello()).into_dyn_node()));
  println!("empty iter        -> {}", label(empty.into_dyn_node()));
size_of: VNode=16 Template=48 TemplateNode=72 DynamicNode=48 Attribute=72 AttributeValue=32 Element=24
clone == original: true; two renders ==: false; same template: true
two placeholders ==: true
()                -> Placeholder
"text"            -> Text("text")
String            -> Text("s")
format_args!      -> Text("1+2")
Some("x")         -> Text("x")
None::<&str>      -> Placeholder
VNode             -> Fragment(len=1)
Ok(VNode)         -> Fragment(len=1)
Err(RenderError)  -> Placeholder
iter of 3 Element -> Fragment(len=3)
empty iter        -> Placeholder

(第一行是 std::mem::size_of 在 64 位 macOS 上的结果。)

  • VNode 本身 16 字节:一个 Rc 指针加一个 usize。克隆它只是引用计数加一;clone == original 为真,两次渲染出的「同一个」VNode 不等,模板却相同。
  • 一个 VNode 放进 {} 也会包成只有一项的 Fragment,因为 DynamicNode 里装 VNode 的地方只有 Fragment。
  • 迭代器走的是一个带标记类型的泛型 impl:impl<T, I> IntoDynNode<FromNodeIterator> for T where T: Iterator<Item = I>, I: IntoVNode,空迭代器给 Placeholder 而不是空 Fragment。IntoDynNode 带一个默认为 () 的类型参数,就是为了让这个泛型 impl 和 &str、Option<T> 那些具体 impl 不冲突。
  • Err 的 Element 放进 {} 变成 Placeholder,impl IntoDynNode for Element 里是 _ => DynamicNode::default(),错误不会从这个位置往上传。错误要从组件函数 return Err(..) 或 ? 出去才会被边界接住。
📝NOTE
没有蠢问题

问: VNode 的 == 只比指针,两次渲染出的节点内容一样也「不等」,那 diff 岂不是每次都要整块替换?

答: diff 不靠 VNode 的 == 判断要不要替换。它先比 Template(release 下三个指针),模板相同就逐个比 dynamic_nodes 和 dynamic_attrs 的内容,文本比字符串、属性比 AttributeValue,只为变了的部分发指令。VNode 的 == 只是这之前的一条快路径:同一个 Rc 原样回来时连动态部分都不用看。真正会被「指针相等」影响到的是组件的 props:props 里有 Element 时,PartialEq 用的就是这个 ==。

小结

  • VNode 是 Rc<VNodeInner> 加一个 Cell<MountId>,16 字节;挂载信息在 runtime 的 slab 里,diff 时从旧节点移到新节点。VNode 相等只比 Rc 指针。
  • VNodeInner 是 key、一个 Copy 的 Template、定长的 dynamic_nodes 和 dynamic_attrs(每个属性槽是一个列表,给 spread 用)。
  • Template 是三个 &'static 切片;static_items_merged() 为真(release)时按指针比,否则(debug)按值比。debug 下路径表是运行期泄漏出来的,指针比较会把相同内容判成不同模板。
  • DynamicNode 有 Component、Text、Placeholder、Fragment(Vec<VNode>) 四种;元素本身永远是静态的。
  • Element = Result<VNode, RenderError>,? 能抛出普通错误和挂起;Err 的 Element 放进 {} 只会变成占位符。
  • IntoDynNode 把 ()、None、空迭代器变成 Placeholder,字符串变成 Text,VNode 和迭代器变成 Fragment。

💬 关联讨论

发起讨论
加载中...