Rust in Everything
基于 dioxus 0.7.10

dioxus-ssr

主线位置:首屏主线里「dioxus_ssr::Renderer 带水合标记渲染」那一步。VirtualDom 已经在服务端建好树、等完 suspense,这一页讲它怎么变成一段 HTML 字符串:模板的静态部分预先切好、缓存,动态部分边走边填,pre_render 时再夹进几种标记,留给浏览器里的水合认领节点。渲染器从哪来、渲染完的字符串怎么发出去,见一次请求的渲染。

flowchart TB
  entry["render_to 根 scope"] --> rscope[render_scope]
  rscope --> rtpl[render_template]
  rtpl --> cache{"缓存里有这个模板"}
  cache -->|没有| build["StringCache::from_template"]
  cache -->|有| segs["按段循环"]
  build --> segs
  segs --> pre["静态段 原样写"]
  segs --> attr["Attr 写动态属性"]
  segs --> node["Node 动态节点"]
  segs --> mark["标记段 写编号"]
  node --> comp{"组件"}
  comp -->|有回调| cb["render_components"]
  comp -->|无回调| rtpl
  node --> txt["文本 加注释对"]
  node --> frag["Fragment 逐个"]
  frag --> rtpl

dioxus-ssr 的源码只有两个有内容的文件:renderer.rs(513 行,其中一半是测试和属性表)和 cache.rs(372 行)。lib.rs 里另有两个公开的空模块 config 和 template,各只有一行,什么也没导出。

入口:Renderer 和三个便捷函数

Renderer 有四个字段:

pub struct Renderer {
    /// Choose to write ElementIDs into elements so the page can be re-hydrated later on
    pub pre_render: bool,

    /// A callback used to render components. You can set this callback to control what components are rendered and add wrappers around components that are not present in CSR
    render_components: Option<ComponentRenderCallback>,

    /// A cache of templates that have been rendered
    template_cache: FxHashMap<Template, Arc<StringCache>>,

    /// The current dynamic node id for hydration
    dynamic_node_id: usize,
}

pre_render 决定写不写水合标记;template_cache 以 Template 为键,存这个模板切好的字符串段;dynamic_node_id 是标记里的编号计数器,render_to 每次开始时由 reset_hydration 清零,然后从根 scope 开始 render_scope。

lib.rs 里的 render、pre_render、render_element 每次都 Renderer::new(),缓存用完就丢;注释也说「We generally recommend creating a new Renderer to take advantage of template caching」。fullstack 服务端不用它们,而是把 Renderer 放进池子里跨请求复用,缓存就一直留着(见一次请求的渲染)。

Renderer 只读 VirtualDom,从不改它:调用者先 rebuild_in_place(指令丢弃,见 Mutations),等 suspense,再把 &VirtualDom 交给渲染器。它从每个 scope 取 root_node(),沿 VNode 走,组件就跳到它挂载的 scope 继续。

StringCache:一个模板切一次

02 rsx 与组件讲过,rsx! 的静态结构是一个 Template,每次渲染产出的 VNode 只带动态部分。SSR 利用的就是这一点:同一个模板的静态 HTML 每次都一样,切好一次就够了。StringCache::from_template 递归走模板的每个根(from_template_recursive),把能确定的文字都写成 PreRendered,确定不了的位置留一个标记段。Segment 一共十种:

段来自渲染时做什么
PreRendered(String)标签、静态属性、静态文本原样写
PreRenderedMaybeEscaped { value, renderer_if_escaped }模板根上的静态文本,一份转义、一份不转义按父模板要不要转义挑一份写
Attr(i)动态属性槽写 dynamic_attrs[i] 里的每个属性,收集样式、dangerous_inner_html 和监听器名
Node { index, escape_text }动态节点槽写 dynamic_nodes[index]:组件、文本、Fragment、占位
StyleMarker { inside_style_tag }有静态样式或动态属性的元素把收集到的动态样式写进 style
InnerHtmlMarker有动态属性的元素的开标签之后写动态的 dangerous_inner_html
HydrationOnlySection(跳到)只在水合时需要的一段的开头不 pre_render 时直接跳到给定下标
AttributeNodeMarker带动态属性的元素写编号和监听器列表
RootNodeMarker模板根元素、根文本写编号

用一个小组件看切出来的样子。cache.rs 和 renderer.rs 原样复制进实验 crate,只在 renderer.rs 末尾加了一个打印缓存的方法:

fn app() -> Element {
  let n = 7;
  rsx! {
    div { class: "card", width: "10px", "data-n": "{n}", onclick: move |_| {},
      "count: "
      "{n}"
      br {}
    }
  }
}

fn main() {
  let mut dom = VirtualDom::new(app);
  dom.rebuild_in_place();
  let mut r = Renderer::new();
  println!("render:     {}", r.render(&dom));
  r.pre_render = true;
  println!("pre_render: {}", r.render(&dom));
  r.dump_segments();
}

debug 构建(M2 Max、macOS 27、rustc 1.99.0):

render:     <div class="card" data-n="7" style="width:10px;">count: 7<br/></div>
pre_render: <div class="card" data-n="7" style="width:10px;" data-node-hydration="0,click:1">count: <!--node-id1-->7<!--#--><br/></div>
template with 1 root(s):
  PreRendered("<div class=\"card\"")
  Attr(0)
  Attr(1)
  PreRendered(" style=\"width:10px;")
  StyleMarker { inside_style_tag: true }
  PreRendered("\"")
  HydrationOnlySection(10)
  PreRendered(" data-node-hydration=\"")
  AttributeNodeMarker
  PreRendered("\"")
  PreRendered(">")
  InnerHtmlMarker
  PreRendered("count: ")
  Node { index: 0, escape_text: Escape }
  PreRendered("<br/></div>")
template with 1 root(s):
  Node { index: 0, escape_text: ParentEscape }

几处细节:

  • width: "10px" 是 style 命名空间的属性,切的时候先收起来,等所有属性写完再合成一个 style="..."。所以输出里 style 排在 data-n 后面,和 rsx! 里的书写顺序不同。
  • "data-n": "{n}" 和 onclick 是两个动态属性槽 Attr(0)、Attr(1),静态的 class 直接进了第一个 PreRendered。
  • HydrationOnlySection(10) 的意思是「不 pre_render 时跳到第 10 段」,第 10 段正是 >。同一份缓存同时服务带标记和不带标记两种渲染,from_template 的注释专门说了这一点:缓存里故意不放渲染模式。实现上是 StringChain::if_hydration_enabled 先放一个占位的 HydrationOnlySection(0),写完中间那段再回填跳转下标,并且把 add_text_to_last_segment 置假,防止后面的静态文本被拼进这段只在水合时才写的文字里。
  • <br/> 自闭合:tag_is_self_closing 列了 14 个空元素,没有子节点时写 />。
  • 第二个模板只有一个 Node,是外层把 app 当组件挂进去的那个模板。release 构建切出的段完全相同,只是缓存里多一个同样只有 Node 的条目:release 下的模板是编译期的 &'static Template,两个外层模板内容一样却是两个不同的键(模板的比较方式见 Template 与 VNode)。

render_template:按段循环

render_template 先 entry(template.template).or_insert_with(...) 拿到缓存,然后用一个手动维护的下标遍历段,遇到 HydrationOnlySection 时直接改下标跳过去:

        while let Some(segment) = entry.segments.get(index) {
            match segment {
                Segment::HydrationOnlySection(jump_to) => {
                    // If we are not prerendering, we don't need to write the content of the hydration only section
                    // Instead we can jump to the next section
                    if !self.pre_render {
                        index = *jump_to;
                        continue;
                    }
                }
                // ...

Attr 段对槽里的每个属性分四路:名字是 dangerous_inner_html 的先存起来,等 InnerHtmlMarker 再写;style 命名空间的攒进 accumulated_dynamic_styles,等 StyleMarker;在 BOOL_ATTRS(28 个布尔属性)里的,值为假就整个不写;其余交给 write_attribute。监听器的值是 AttributeValue::Listener,write_attribute 对它什么也不写,但 pre_render 时会把名字记进 accumulated_listeners(onmounted 除外,它不需要 DOM 监听器),留给后面的 AttributeNodeMarker。

Node 段按 DynamicNode 的四种分支:

  • Component:有回调就交给回调(最后一节),没有就找到挂载的 scope,对它的 root_node() 递归 render_template,并把这一层算出的「要不要转义」传下去。
  • Text:写文本,pre_render 时前后加 <!--node-idN--> 和 <!--#-->,编号加一。
  • Fragment:对里面每个 VNode 递归,for 循环和 {iter} 产出的就是它。
  • Placeholder:不 pre_render 时什么也不写;pre_render 时写 <!--placeholderN-->,编号加一。

三种水合标记

浏览器解析 HTML 得到的 DOM 和 VirtualDom 里的树并不一一对应:相邻的文本会合并成一个文本节点,空文本根本不产生节点,什么也不渲染的占位在 HTML 里没有对应物。服务端也不知道客户端会给每个节点分什么 ElementId。标记就是为了补上这些信息。下面这个组件把各种情况放在一起:

#[component]
fn Child(label: String) -> Element {
  // two template roots: an element and a static text node
  rsx! {
    b { "{label}" }
    "tail"
  }
}

fn app() -> Element {
  let name = "x";
  let empty = String::new();
  let missing: Option<Element> = None;
  rsx! {
    div { class: "static",
      "static text "
      "{name}"
      "{empty}"
      {missing}
      span { id: "s-{name}", onclick: move |_| {}, onmounted: move |_| {}, "inner" }
      for i in 0..2 {
        i { "{i}" }
      }
      Child { label: "c" }
    }
  }
}

fn main() {
  let mut dom = VirtualDom::new(app);
  dom.rebuild_in_place();
  println!("render:     {}", dioxus_ssr::render(&dom));
  println!("pre_render: {}", dioxus_ssr::pre_render(&dom));
  // the same tree on a fresh dom, this time keeping the mutations: which ElementId gets what
  let mut dom2 = VirtualDom::new(app);
  for edit in dom2.rebuild_to_vec().edits {
    println!("{edit:?}");
  }
}

输出(debug 构建):

render:     <div class="static">static text x<span id="s-x">inner</span><i>0</i><i>1</i><b>c</b>tail</div>
pre_render: <div class="static" data-node-hydration="0">static text <!--node-id1-->x<!--#--><!--node-id2--><!--#--><!--placeholder3--><span id="s-x" data-node-hydration="4,click:1">inner</span><i data-node-hydration="5"><!--node-id6-->0<!--#--></i><i data-node-hydration="7"><!--node-id8-->1<!--#--></i><b data-node-hydration="9"><!--node-id10-->c<!--#--></b><!--node-id11-->tail<!--#--></div>
LoadTemplate { index: 0, id: ElementId(1) }
AssignId { path: [4], id: ElementId(2) }
SetAttribute { name: "id", ns: None, value: Text("s-x"), id: ElementId(2) }
NewEventListener { name: "click", id: ElementId(2) }
NewEventListener { name: "mounted", id: ElementId(2) }
LoadTemplate { index: 0, id: ElementId(3) }
CreateTextNode { value: "c", id: ElementId(4) }
ReplacePlaceholder { path: [0], m: 1 }
LoadTemplate { index: 1, id: ElementId(5) }
ReplacePlaceholder { path: [6], m: 2 }
LoadTemplate { index: 0, id: ElementId(6) }
CreateTextNode { value: "0", id: ElementId(7) }
ReplacePlaceholder { path: [0], m: 1 }
LoadTemplate { index: 0, id: ElementId(8) }
CreateTextNode { value: "1", id: ElementId(9) }
ReplacePlaceholder { path: [0], m: 1 }
ReplacePlaceholder { path: [5], m: 2 }
CreatePlaceholder { id: ElementId(10) }
ReplacePlaceholder { path: [3], m: 1 }
CreateTextNode { value: "", id: ElementId(11) }
ReplacePlaceholder { path: [2], m: 1 }
CreateTextNode { value: "x", id: ElementId(12) }
ReplacePlaceholder { path: [1], m: 1 }
AppendChildren { id: ElementId(0), m: 1 }

不带标记的 render 输出里,static text x 在浏览器里会被解析成一个文本节点,空文本和 None 什么也没留下。带标记的版本逐个补回来:

标记写在哪补的是什么
data-node-hydration="N"模板的根元素(RootNodeMarker)客户端要拿着根元素的 ElementId,删除、替换整个模板时用
data-node-hydration="N,click:1"带动态属性的元素(AttributeNodeMarker)编号,后面跟这个元素上的每个监听器:事件名去掉 on,冒号后是 event_bubbles 的 0 / 1,客户端据此直接挂监听器
<!--node-idN-->文本<!--#-->动态文本、模板根上的静态文本文本的起止。没有它们,static text 和 x 合成一个节点;空文本(编号 2)在 DOM 里两个注释紧挨着,客户端看到这种情况会自己插一个空文本节点
<!--placeholderN-->占位(None、空列表、挂起的位置)占位在 DOM 里就是这个注释节点本身,客户端直接认领它

注意 span 上的 onmounted 没有出现在监听器列表里。

为什么写编号,而不直接写 ElementId?看指令列表:客户端建树时,同一个根元素里的动态节点是倒着建的(Child 先拿到 3、4、5,然后是 for 循环的 6 到 9,占位 10,空文本 11,x 最后拿到 12;倒序的原因见 core 的 load_placeholders 里那段「VERY IMPORTANT」注释)。而 Renderer 按文档顺序写,编号 0 到 11 只表示「第几个需要认领的位置」。客户端水合时按同样的文档顺序遍历自己的 VNode 树,把每个位置上的 ElementId 依次放进一个数组,ids[编号] 就是那个节点的 ElementId:这里是 [1, 12, 11, 10, 2, 6, 7, 8, 9, 3, 4, 5]。这一步的代码在水合。

两边能对上,前提是遍历顺序一致:服务端写标记的顺序(段的顺序,也就是文档顺序)和客户端 rehydrate_template_node 收集 ids 的顺序。根元素无论有没有动态属性都要编号,根文本也要,因为客户端那边 vnode.mounted_root(i) 对每个根都有一个 ElementId。

转义

转义一律用 askama_escape 0.13 的 Html 模式,把 <、>、&、"、' 换成 &#60; 这类数字实体。哪里转、哪里不转:

const EVIL: &str = "</script><img src=x onerror=alert(1)> & \" '";

#[component]
fn RootText(s: String) -> Element {
  // a template whose only root is dynamic text: escaping depends on the parent template
  rsx! { "{s}" }
}

fn app() -> Element {
  let evil = EVIL.to_string();
  rsx! {
    p { "static <b> & \" '" }
    p { "{evil}" }
    p { title: "{evil}", "dyn attr" }
    p { title: "static \" <b>", "static attr" }
    p { "data-x": "{evil}", "dyn custom attr" }
    p { width: "{evil}", "dyn style" }
    script { "{evil}" }
    script { RootText { s: evil.clone() } }
    p { RootText { s: evil.clone() } }
    style { "{evil}" }
    div { dangerous_inner_html: "{evil}" }
    div { dangerous_inner_html: "<i>static</i>" }
    input { disabled: false, hidden: true, readonly: "false", checked: "{evil}" }
  }
}

dioxus_ssr::render 的输出,按 </p>、</script> 断行:

<p>static &#60;b&#62; &#38; &#34; &#39;</p>
<p>&#60;/script&#62;&#60;img src=x onerror=alert(1)&#62; &#38; &#34; &#39;</p>
<p title="&#60;/script&#62;&#60;img src=x onerror=alert(1)&#62; &#38; &#34; &#39;">dyn attr</p>
<p title="static &#34; &#60;b&#62;">static attr</p>
<p data-x="&#60;/script&#62;&#60;img src=x onerror=alert(1)&#62; &#38; &#34; &#39;">dyn custom attr</p>
<p style="width:&#60;/script&#62;&#60;img src=x onerror=alert(1)&#62; &#38; &#34; &#39;;">dyn style</p>
<script></script>
<img src=x onerror=alert(1)> & " '</script>
<script></script>
<img src=x onerror=alert(1)> & " '</script>
<p>&#60;/script&#62;&#60;img src=x onerror=alert(1)&#62; &#38; &#34; &#39;</p>
<style></script>
<img src=x onerror=alert(1)> & " '</style><div></script>
<img src=x onerror=alert(1)> & " '</div><div><i>static</i></div><input hidden=true checked="&#60;/script&#62;&#60;img src=x onerror=alert(1)&#62; &#38; &#34; &#39;"/>
位置转义依据
普通元素里的文本,静态和动态转EscapeText::Escape
属性值(静态、动态、自定义 data-*)和样式值转,值放在双引号里write_attribute、write_value_unquoted、切缓存时的静态属性
script、style 里的文本,静态和动态不转切缓存时标签是 script / style 的子节点得到 EscapeText::NoEscape
模板根上的文本(RootText 这种组件)看它被挂在哪:父模板在 script 里就不转,否则转EscapeText::ParentEscape,静态的根文本存两份 PreRenderedMaybeEscaped
dangerous_inner_html,静态和动态不转,原样写进标签之间这就是这个属性的用途
布尔属性值为假(false、0、空串、"false")整个不写;为真时按原值写truthy

两点要看清:

  • script { "{evil}" } 输出的 </script> 提前结束了脚本,后面的 <img onerror> 成了真正的元素。这是有意的设计,ssr/tests/escape.rs 里有一个测试就叫 don_t_escape_dynamic_scripts:script 里的东西被当成代码,由写代码的人负责。如果要把数据塞进 <script>(比如一段 JSON),自己先把 </ 处理掉,或者改用属性传值。
  • 动态的布尔、整数、浮点属性写成不带引号的 name=value:hidden: true 输出 hidden=true。HTML 解析器对布尔属性只看在不在,这样写没问题。
📝NOTE
冷知识:script 里的文本曾经也被转义

0.6.3 的 cache.rs 对所有静态文本都调 askama_escape::escape,renderer.rs 对所有动态文本也是,不分标签。PR #3933(2025-04-08 合并,首次随 v0.7.0-alpha.0 发布)的说明里写道:修好一个指错端口的 fullstack 测试之后,head 元素的测试失败了,原因之一是「We try to escape text inside of script and style tags which breaks the tags」:脚本和样式里的 <、&、引号被换成实体,浏览器不会把它们还原,代码就坏了。这个 PR 加了上面表格里的 EscapeText 三个分支,script / style 之下不转义,模板根上的文本推迟到渲染时看父模板,还新增了 ssr/tests/escape.rs 里那一组测试。

组件回调:留给流式渲染的口子

set_render_components 装一个回调,签名是 Fn(&mut Renderer, &mut dyn Write, &VirtualDom, ScopeId) -> fmt::Result。装了之后,Node 段遇到组件不再自己递归,而是取出这个组件挂载的 ScopeId,连同渲染器本身一起交给回调;回调可以在组件内容前后写别的东西,也可以调 render_scope 让渲染器照常写下去。

fullstack 服务端在每次渲染前都装上 SsrRendererPool::streaming_render_component_callback:它看这个 scope 是不是一个还有挂起任务的 suspense 边界,是就把它的内容包进一个可以后来替换的占位里,并记下这个边界;不是就直接 render_scope。默认的 StreamingMode::Disabled 下,渲染时所有 suspense 都已经完成,这个回调每次都走后一个分支,输出和不装回调一样。占位长什么样、之后怎么替换,见流式渲染。

回调里拿到的是 &mut Renderer,所以嵌套组件继续走同一个 dynamic_node_id 计数器,编号在整页里是连续的。流式渲染替换某个边界时,会先 reset_hydration 再 render_scope,那一段的编号从 0 重新数,和客户端对那个边界单独水合时的顺序对应。

❗IMPORTANT
动脑筋:编号 1 的文本节点,客户端会给它分哪个 ElementId?

上面「三种水合标记」一节的组件,pre_render 输出里 <!--node-id1-->x<!--#--> 包着的文本 x,在同一棵树由客户端 rebuild 时,会分到 ElementId 几?(M2 Max、macOS 27、rustc 1.99.0,原生 VirtualDom,rebuild_to_vec()。)

动脑筋答案

那次运行里对应的两行(原样):

CreateTextNode { value: "x", id: ElementId(12) }
ReplacePlaceholder { path: [1], m: 1 }

是 12,不是 2。div 这个根元素里的动态节点按下标倒着建:下标最大的 Child 先建,x 是下标 0 的动态节点,最后建,拿到最后一个 id。服务端的编号按文档顺序数,x 排第 1。两边的数字没有任何关系,水合时靠客户端按文档顺序收集的 ids 数组把编号换成 ElementId:ids[1] = 12。

小结

  • Renderer 只读 VirtualDom:从根 scope 的 root_node() 出发,按模板切好的段写 HTML,组件跳到它的 scope 接着写。
  • 每个 Template 只切一次 StringCache,以模板为键缓存在渲染器里;同一份缓存靠 HydrationOnlySection 跳转同时服务带标记和不带标记的渲染。lib.rs 的便捷函数每次新建渲染器,缓存不复用。
  • pre_render 的三种标记:根元素和带动态属性的元素上的 data-node-hydration="N[,事件:冒泡]",文本两边的 <!--node-idN--> / <!--#-->,占位的 <!--placeholderN-->。编号按文档顺序数,和客户端的 ElementId 无关,客户端倒序建节点,靠 ids[编号] 对上。
  • 文本和属性用 askama_escape 转义;script、style 里的文本和 dangerous_inner_html 不转义,script { "{x}" } 里的 </script> 会提前结束脚本。
  • set_render_components 让流式渲染在每个组件处插手,默认不流式时它的效果和不装一样。

💬 关联讨论

发起讨论
加载中...