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 模式,把 <、>、&、"、' 换成 < 这类数字实体。哪里转、哪里不转:
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 <b> & " '</p>
<p></script><img src=x onerror=alert(1)> & " '</p>
<p title="</script><img src=x onerror=alert(1)> & " '">dyn attr</p>
<p title="static " <b>">static attr</p>
<p data-x="</script><img src=x onerror=alert(1)> & " '">dyn custom attr</p>
<p style="width:</script><img src=x onerror=alert(1)> & " ';">dyn style</p>
<script></script>
<img src=x onerror=alert(1)> & " '</script>
<script></script>
<img src=x onerror=alert(1)> & " '</script>
<p></script><img src=x onerror=alert(1)> & " '</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="</script><img src=x onerror=alert(1)> & " '"/>
| 位置 | 转义 | 依据 |
|---|---|---|
| 普通元素里的文本,静态和动态 | 转 | 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 解析器对布尔属性只看在不在,这样写没问题。
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 重新数,和客户端对那个边界单独水合时的顺序对应。
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让流式渲染在每个组件处插手,默认不流式时它的效果和不装一样。