rsx! 展开成什么
主线位置:更新主线里
run_scope重跑组件,组件函数返回的那个VNode就是本页这段宏展开在运行期的产物;首屏主线里服务端每渲染一个组件也是同一段代码。本页只讲宏这一侧:rsx!在编译期把一棵树切成哪几块、给动态部分怎么编号。运行期这些数据结构长什么样在 Template 与 VNode,新旧两棵怎么比在 diff。
flowchart TB tokens["rsx 的 token"] --> callbody[CallBody 解析] callbody --> bodynode["BodyNode 六种节点"] bodynode --> visitor["DynIdVisitor 编号"] visitor --> idx["cascade 模板序号"] idx --> totokens["TemplateBody 生成代码"] totokens --> rel["release: static Template"] totokens --> dbg["debug: 热重载表"] rel --> vnode["VNode::new"] dbg --> vnode
rsx! 像是在编译期印一张表格。表格上印死的标题、栏目名(TemplateNode::Element、TemplateNode::Text、TemplateAttribute::Static)每次都一样;需要每次现填的格子印成带编号的空格(TemplateNode::Dynamic { id }、TemplateAttribute::Dynamic { id }),旁边另附一张「第几个空在第几页第几行」的索引(node_paths、attr_paths)。组件每跑一次,就拿同一张表、按编号填一遍空(dynamic_nodes、dynamic_attrs)。两次填的表要比较时,只看空格里写了什么。
比方到这里为止:真实的空格里不只能写字,还能贴进另一整张表(for、if 的结果是嵌套的 VNode),每张小表也是自己印好的。
源码在 packages/rsx/src(约 5.7k 行),入口在 packages/core-macro/src/lib.rs。dioxus::prelude::rsx 就是这个过程宏。
从 token 到 CallBody
过程宏本身只有几行,rsx:
#[proc_macro]
pub fn rsx(tokens: TokenStream) -> TokenStream {
match syn::parse::<rsx::CallBody>(tokens) {
Err(err) => err.to_compile_error().into(),
Ok(body) => body.into_token_stream().into(),
}
}
解析和生成都在 dioxus-rsx 里。CallBody 是整个宏调用的内容:一个根 TemplateBody,加一个给模板发序号的计数器 template_idx。TemplateBody 是「一组处在模板根位置的节点」,里面每个节点是一个 BodyNode:
pub enum BodyNode {
/// div {}
Element(Element),
/// Component {}
Component(Component),
/// "text {formatted}"
Text(TextNode),
/// {expr}
RawExpr(ExprNode),
/// for item in items {}
ForLoop(ForLoop),
/// if cond {} else if cond {} (else {}?)
IfChain(IfChain),
}
BodyNode::parse 只看开头一两个 token 就决定是哪一种:字符串字面量是 Text;for、if 各自对应;match 和花括号都当 RawExpr;带 - 的标识符(my-element)是 web component,按元素处理;单个标识符、首字母小写、不含下划线是元素;其余(大写开头、带下划线、带 :: 的路径)都是组件。所以 my_button {} 会被当成组件,div {} 不会。
一个 rsx! 里有几个模板
一个 rsx! 不一定只生成一个模板。CallBody::new 解析完之后,用 cascade_hotreload_info 走一遍整棵树,给每个嵌套的 TemplateBody 发一个序号:
fn cascade_hotreload_info(&self, nodes: &[BodyNode]) {
for node in nodes.iter() {
match node {
BodyNode::Element(el) => {
self.cascade_hotreload_info(&el.children);
}
BodyNode::Component(comp) => {
comp.children.template_idx.set(self.next_template_idx());
self.cascade_hotreload_info(&comp.children.roots);
}
BodyNode::ForLoop(floop) => {
floop.body.template_idx.set(self.next_template_idx());
self.cascade_hotreload_info(&floop.body.roots);
}
BodyNode::IfChain(chain) => chain.for_each_branch(&mut |body| {
body.template_idx.set(self.next_template_idx());
self.cascade_hotreload_info(&body.roots)
}),
_ => {}
}
}
}
根是 0 号,之后按深度优先:组件的 children、for 的循环体、if 链的每一个分支(包括 else)各占一个序号,各自生成一个独立的模板。元素的子节点不另起模板,它们是父模板静态树的一部分。match 臂里写的 rsx! 是另一次宏调用,有自己的 CallBody,序号从 0 重新数。
这样切的原因在运行期:循环体会跑 N 次、if 只会选中一个分支、children 由父组件生成却挂在子组件里,它们都不能和外层一起印进同一张静态树,只能各印一张,外层在对应位置留一个动态槽。没有 else 的 if 不生成 else 模板,展开时补的是 VNode::empty(),一个占位符。
下面这个例子贯穿本页:
fn app() -> Element {
let n = 2;
rsx! {
div { class: "box", id: "item-{n}",
h1 { "Hello" }
"n = {n}"
for i in 0..n {
li { "{i}" }
}
if n > 1 {
span { "big" }
}
}
}
}
它有三个模板:根(div 那棵)、for 的循环体(li)、if 的分支(span)。debug 构建的展开里,每个模板都带着自己的序号(下一节讲这段代码是什么),三处分别是:
5u32,
3u32,
0usize,
...
5u32,
3u32,
1usize,
...
5u32,
3u32,
2usize,
5u32, 3u32 是 rsx! 所在的行和列,三个模板共用;最后一个数就是 cascade_hotreload_info 发的序号。
编号:DynIdVisitor
每个 TemplateBody 在 TemplateBody::new 里给自己的节点编号,干活的是 DynIdVisitor。它维护一条 current_path(从根往下每一层的下标),深度优先走一遍,visit 的规则是:
fn visit(&mut self, node: &BodyNode) {
match node {
// Just descend into elements - they're not dynamic
BodyNode::Element(el) => {
for (idx, attr) in el.merged_attributes.iter().enumerate() {
if !attr.is_static_str_literal() {
self.assign_path_to_attribute(attr, idx);
// ...
}
}
// ...
self.visit_children(&el.children);
}
// Text nodes are dynamic if they contain dynamic segments
BodyNode::Text(txt) => {
if !txt.is_static() {
self.assign_path_to_node(node);
self.assign_formatted_segment(&txt.input);
}
}
// Raw exprs are always dynamic
BodyNode::RawExpr(_) | BodyNode::ForLoop(_) | BodyNode::IfChain(_) => {
self.assign_path_to_node(node)
}
BodyNode::Component(component) => {
self.assign_path_to_node(node);
// ...
}
};
}
assign_path_to_node 把 node_paths 当前的长度当作这个节点的动态序号,再把 current_path 压进 node_paths;属性那边同理,attr_paths 里存的是「元素的路径 + 它在 merged_attributes 里的下标」。所以:
- 动态节点和动态属性各自从 0 编号,按深度优先的出现顺序。
- 编号只在一个模板内部有效。
for循环体里的"{i}"是循环体模板的 0 号,和外层的 0 号无关。 - 元素自己永远不是动态的;组件、
for、if、{expr}永远是动态的;文本和属性看内容。
例子的根模板编号结果(运行期打出来的,见后文):
| 动态部分 | 序号 | 路径 |
|---|---|---|
id: "item-{n}" | dynamic_attrs[0] | attr_paths[0] = [0](div) |
"n = {n}" | dynamic_nodes[0] | node_paths[0] = [0, 1] |
for i in 0..n | dynamic_nodes[1] | node_paths[1] = [0, 2] |
if n > 1 | dynamic_nodes[2] | node_paths[2] = [0, 3] |
路径的第一个数是根的下标(div 是 0 号根),后面是一层层子节点的下标:h1 是 div 的 0 号孩子,"n = {n}" 是 1 号,所以是 [0, 1]。
什么算静态:只有字符串字面量
一段文本或一个属性值能不能印进模板,只有一条标准:它是不是一个不含任何 {} 洞的字符串字面量。文本的判断是 IfmtInput::is_static:
pub fn is_static(&self) -> bool {
self.segments
.iter()
.all(|seg| matches!(seg, Segment::Literal(_)))
}
字符串先被切成 Literal 和 Formatted 两种片段,{{、}} 是转义,属于 Literal。属性的判断是 Attribute::as_static_str_literal:
pub fn as_static_str_literal(&self) -> Option<(&AttributeName, &IfmtInput)> {
match &self.value {
AttributeValue::AttrLiteral(lit) => match &lit {
HotLiteral::Fmted(input) if input.is_static() => Some((&self.name, input)),
_ => None,
},
_ => None,
}
}
只有 HotLiteral::Fmted(字符串)可能是静态的;width: 100 是 HotLiteral::Int,hidden: false 是 HotLiteral::Bool,都走动态属性。原因在模板一侧:TemplateAttribute 的 value 是 &'static str,整数和布尔要变成 AttributeValue::Int / Bool 交给渲染器,没有字符串形式可存。
同名属性在编号之前先被 merge_attributes 合并:同名的几个值拼成一个格式化字符串,中间插一个空格。几个都是字面量时合并结果仍是静态的(class: "a", class: "b" 变成静态的 "a b");其中有一个是 if 表达式,合并结果就带一个洞,变成动态。
问: lang: "{GREETING}" 里的 GREETING 是个 const,值在编译期就定了,为什么还是动态属性?
答: 过程宏只看得到 token,看不到类型和常量的值。"{GREETING}" 在宏眼里就是一个带洞的字符串,洞里是一个标识符,它不知道这个标识符是 const 还是局部变量,只能生成 format! 让它运行期求值。{"from expr"} 也一样:花括号里是任意表达式,一律是 RawExpr。代价是每次渲染多一次格式化和一个 String,diff 时再比一次字符串。想让它进模板,就直接写字面量。
cargo expand:release 下展开的样子
cargo expand --offline --release --bin expand_small 展开上面的例子。根模板的展开如下(删了 for 和 if 两段的内部,它们和根模板的结构相同):
fn app() -> Element {
let n = 2;
dioxus_core::Element::Ok({
let __key = None;
let __dynamic_nodes: [dioxus_core::DynamicNode; 3usize] = [
dioxus_core::DynamicNode::Text(
dioxus_core::VText::new(
::alloc::__export::must_use({
::alloc::fmt::format(format_args!("n = {0}", n))
}),
),
),
{
let ___nodes = dioxus_core::IntoDynNode::into_dyn_node(
(0..n)
.into_iter()
.map(|i| {
dioxus_core::Element::Ok({
// ... 循环体模板,dynamic_nodes[0] 是
// dioxus_core::VText::new((i).to_string())
})
}),
);
___nodes
},
{
let ___nodes = dioxus_core::IntoDynNode::into_dyn_node(
if n > 1 {
{
dioxus_core::Element::Ok({
// ... span 模板,没有动态部分
})
}
} else {
dioxus_core::VNode::empty()
},
);
___nodes
},
];
let __dynamic_attributes: [Box<[dioxus_core::Attribute]>; 1usize] = [
Box::new([
{
dioxus_core::Attribute::new(
dioxus_elements::div::id.0,
::alloc::__export::must_use({
::alloc::fmt::format(format_args!("item-{0}", n))
}),
dioxus_elements::div::id.1,
dioxus_elements::div::id.2,
)
},
]),
];
#[doc(hidden)]
static __TEMPLATE_ROOTS: &[dioxus_core::TemplateNode] = &[
{
dioxus_core::TemplateNode::Element {
tag: dioxus_elements::elements::div::TAG_NAME,
namespace: dioxus_elements::div::NAME_SPACE,
attrs: &[
dioxus_core::TemplateAttribute::Static {
name: dioxus_elements::div::class.0,
namespace: dioxus_elements::div::class.1,
value: "box",
},
dioxus_core::TemplateAttribute::Dynamic {
id: 0usize,
},
],
children: &[
{
dioxus_core::TemplateNode::Element {
tag: dioxus_elements::elements::h1::TAG_NAME,
namespace: dioxus_elements::h1::NAME_SPACE,
attrs: &[],
children: &[
dioxus_core::TemplateNode::Text {
text: "Hello",
},
],
}
},
dioxus_core::TemplateNode::Dynamic {
id: 0usize,
},
dioxus_core::TemplateNode::Dynamic {
id: 1usize,
},
dioxus_core::TemplateNode::Dynamic {
id: 2usize,
},
],
}
},
];
{
#[doc(hidden)]
static ___TEMPLATE: dioxus_core::Template = dioxus_core::Template {
roots: __TEMPLATE_ROOTS,
node_paths: &[&[0u8, 1u8], &[0u8, 2u8], &[0u8, 3u8]],
attr_paths: &[&[0u8]],
};
#[allow(clippy::let_and_return)]
let __vnodes = dioxus_core::VNode::new(
__key,
___TEMPLATE,
Box::new(__dynamic_nodes),
Box::new(__dynamic_attributes),
);
__vnodes
}
})
}
逐段看,这些都来自 TemplateBody 的 ToTokens:
- 外壳永远是
Element::Ok。rsx!本身不会失败,Err只来自组件里的?(见 Suspense 与错误边界)。 - 动态部分是两个定长数组。 长度在编译期就知道(
3usize、1usize),每次渲染按编号顺序求值一遍。dynamic_attrs的每个槽是Box<[Attribute]>而不是单个属性,因为..attrs展开会往同一个槽里放多个属性。 - 静态部分是
static。__TEMPLATE_ROOTS和___TEMPLATE都是static,整个程序里每个rsx!模板只有一份,Template只是三个&'static切片,VNode::new按值拷进去(它是Copy)。路径直接是字面量数组。 - 元素名和属性名经过
dioxus_elements。dioxus_elements::elements::div::TAG_NAME、dioxus_elements::div::id.0是dioxus-html里的常量,名字拼错就编译不过;属性的.1是命名空间,.2是 volatile 标记。 for变成into_iter().map(...),if变成if ... else。 两者都交给IntoDynNode::into_dyn_node变成一个DynamicNode(ForLoop、IfChain的ToTokens),迭代器和Element各自变成什么,在 Template 与 VNode 里有实验。- 单洞字符串在 release 下不走
format!。 循环体里的"{i}"展开成(i).to_string(),"n = {n}"才是format!。这是IfmtInput的ToTokens里if !cfg!(debug_assertions)包着的try_to_string。注意这个cfg!判断的是过程宏 crate 自己编译时的设置,cargo 的 release profile 下它是关的。
组件节点展开成 fc_to_builder(Row).label(..).count(..).build().into_vcomponent(Row),这一串在 #[component] 与 Props 里讲。
debug 构建:同一棵树,换一条路
同一个例子不加 --release 展开,文件从 199 行变成 528 行。__TEMPLATE_ROOTS 和两个动态数组完全一样,变的是最后怎么得到 VNode。TemplateBody::to_tokens 里 #[cfg(debug_assertions)] 的几段分别展开成:
fn __original_template() -> &'static dioxus_core::internal::HotReloadedTemplate {
static __ORIGINAL_TEMPLATE: ::std::sync::OnceLock<
dioxus_core::internal::HotReloadedTemplate,
> = ::std::sync::OnceLock::new();
if __ORIGINAL_TEMPLATE.get().is_none() {
_ = __ORIGINAL_TEMPLATE
.set(
dioxus_core::internal::HotReloadedTemplate::new(
None,
// ... 两个 vec:HotReloadDynamicNode::Dynamic(0..=2)、HotReloadDynamicAttribute::Dynamic(0)
::alloc::vec::Vec::new(),
__TEMPLATE_ROOTS,
),
);
}
__ORIGINAL_TEMPLATE.get().unwrap()
}
let __template_read = {
use dioxus_signals::ReadableExt;
// ... __NORMALIZED_FILE = "src/bin/expand_small.rs"
static __TEMPLATE: dioxus_signals::GlobalSignal<
Option<dioxus_core::internal::HotReloadedTemplate>,
> = dioxus_signals::GlobalSignal::with_location(
|| None::<dioxus_core::internal::HotReloadedTemplate>,
__NORMALIZED_FILE,
5u32,
3u32,
0usize,
);
dioxus_core::Runtime::try_current()
.map(|_| __TEMPLATE.read())
};
// ... 没有被热重载替换过就用 __original_template()
let mut __dynamic_literal_pool = dioxus_core::internal::DynamicLiteralPool::new(
// ... 一个 vec,两项都是 format!("{0}", n) 再 .to_string()
);
// ... __dynamic_nodes / __dynamic_attributes / __TEMPLATE_ROOTS 同 release
{
let mut __dynamic_value_pool = dioxus_core::internal::DynamicValuePool::new(
Vec::from(__dynamic_nodes),
Vec::from(__dynamic_attributes),
__dynamic_literal_pool,
);
__dynamic_value_pool.render_with(__template_read)
}
(宏里的 vec![...] 被 cargo expand 展开成很长的 box_assume_init_into_vec_unsafe(...),上面删掉了,用 // ... 注释说明里面是什么。)
这是热重载的接口:每个模板按「文件、行、列、序号」挂一个 GlobalSignal,dx serve 改了 rsx! 里的静态部分时,往这个 signal 里塞一个新的 HotReloadedTemplate,读过它的组件就会重跑。热重载本身不在本卷范围内,这里只关心两点。
第一,debug 下 Template 不是宏写死的。HotReloadedTemplate::new 只拿 roots,node_paths / attr_paths 在运行期从 roots 重新算一遍(node_paths 深度优先找 TemplateNode::Dynamic,debug_assert_eq!(node_paths.len(), *id) 核对顺序),用 Box::leak 换成 &'static,每个调用点只算一次(OnceLock)。release 展开里的 node_paths: &[...] 在 debug 下根本不生成。这个差别会影响模板的比较方式,见 Template 与 VNode。
第二,debug 下每个 {} 洞要多格式化一次。__dynamic_literal_pool 把每个洞单独 format! 再 to_string() 一遍,留给热重载改写文本时用;VText 和 Attribute 里的完整字符串照样格式化。例子里的 n 出现在两个洞里,debug 下每次渲染格式化四次,release 下两次。
两种构建得到的结构是一样的。实验 templates 在组件外直接调 app(),沿 VNode 打出模板地址、路径和动态部分(dump 函数递归进 Fragment,代码略),debug 输出:
debug_assertions = true
template roots@0x1047a04e8 node_paths=[[0, 1], [0, 2], [0, 3]] attr_paths=[[0]]
dynamic_nodes[0] = Text("n = 2")
dynamic_nodes[1] = Fragment(len=2)
template roots@0x1047a01d8 node_paths=[[0, 0]] attr_paths=[]
dynamic_nodes[0] = Text("0")
template roots@0x1047a01d8 node_paths=[[0, 0]] attr_paths=[]
dynamic_nodes[0] = Text("1")
dynamic_nodes[2] = Fragment(len=1)
template roots@0x1047a02a0 node_paths=[] attr_paths=[]
dynamic_attrs[0] = ["id=Text(\"item-2\")"]
--release 下除了第一行和地址,逐字相同。三种 roots 地址对应三个模板;for 跑了两次,两个 li 的 VNode 指向同一个模板,只有 dynamic_nodes[0] 不同。
路径给谁用
node_paths 的第一个数是根的下标,渲染器那边看到的路径要去掉它。创建一个模板时,core 先让渲染器整块克隆静态树,再把每个动态槽里的占位节点换成真正的内容,diff/node.rs 里替换占位节点的那一行是:
// 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);
例子第一次渲染(rebuild_to_vec() 的 Mutations.edits,压成一行一条)里能看到这一点:根模板 node_paths 的 [0, 3]、[0, 2]、[0, 1] 变成了 ReplacePlaceholder 的 [3]、[2]、[1]:
[ LoadTemplate { index: 0, id: ElementId( 1, ), },
SetAttribute { name: "id", ns: None, value: Text( "item-2", ), id: ElementId( 1, ), },
LoadTemplate { index: 0, id: ElementId( 2, ), },
ReplacePlaceholder { path: [ 3, ], m: 1, },
LoadTemplate { index: 0, id: ElementId( 3, ), },
CreateTextNode { value: "0", id: ElementId( 4, ), },
ReplacePlaceholder { path: [ 0, ], m: 1, },
LoadTemplate { index: 0, id: ElementId( 5, ), },
CreateTextNode { value: "1", id: ElementId( 6, ), },
ReplacePlaceholder { path: [ 0, ], m: 1, },
ReplacePlaceholder { path: [ 2, ], m: 2, },
CreateTextNode { value: "n = 2", id: ElementId( 7, ), },
ReplacePlaceholder { path: [ 1, ], m: 1, },
AppendChildren { id: ElementId( 0, ), m: 1, },
]
这串指令的顺序(为什么动态节点倒着填、m 是什么)在 Mutations。Template 字段上的文档注释说存下来的路径「will be one segment shorter than the path sent to the renderer」,和代码正好相反:存下来的长一段,发给渲染器的短一段。
attr_paths 除了用来给动态属性定位,还用在事件上:Runtime::handle_event 拿事件目标的路径和每个 attr_paths 比 is_descendant,找出路径上挂着的监听器。
空 rsx! 和 key
core 要求每个模板至少有一个根,方便整块替换。normalized 在生成代码前检查:一个节点都没有,就当成写了 {()},而 () 的 IntoDynNode 是占位符。实验 empty_rsx:
let v = rsx! {}.unwrap();
println!("roots={:?} node_paths={:?} dynamic_nodes={:?}", v.template.roots, v.template.node_paths, v.dynamic_nodes);
println!("same Rc as VNode::placeholder(): {}", v == VNode::placeholder());
roots=[Dynamic { id: 0 }] node_paths=[[0]] dynamic_nodes=[Placeholder(VPlaceholder)]
same Rc as VNode::placeholder(): false
形状和 VNode::placeholder() 一样,但是一个新的 Rc,不是那个线程内共享的占位 VNode。
key 不进模板,也不进动态属性:merge_attributes 跳过它,展开里的 let __key = ... 取的是第一个根上的 key。validate_key 要求它是带洞的格式化字符串,写成静态字符串会报「Key must not be a static string. Make sure to use a formatted string like key: "{value}"」;写在第二个及以后的根上只给警告「Keys are only allowed on the first node in the block.」。key 在列表 diff 里怎么用,见 带 key 的列表。
div 的模板里有几个 TemplateAttribute::Dynamic,dynamic_nodes 有几项?M2 Max、macOS 27、rustc 1.99.0,debug 构建,实验 staticness。先按本页的规则数一遍,再看答案。
const GREETING: &str = "hi";
fn app() -> Element {
let x = 7;
let on = true;
rsx! {
div {
class: "a",
class: "b",
title: "{{braces}}",
width: 100,
hidden: false,
lang: "{GREETING}",
dir: if on { "rtl" },
"plain"
"{{not a hole}}"
"{x}"
"{x:>3}"
{"from expr"}
}
}
}
程序在组件外调 app(),递归打印 v.template.roots,再逐项打印 dynamic_nodes 和 dynamic_attrs。
动脑筋答案
Element <div>
attr Static class="a b"
attr Static title="{braces}"
attr Dynamic { id: 0 }
attr Dynamic { id: 1 }
attr Dynamic { id: 2 }
attr Dynamic { id: 3 }
Text "plain"
Text "{not a hole}"
Dynamic { id: 0 }
Dynamic { id: 1 }
Dynamic { id: 2 }
dynamic_nodes[0] = Text(VText { value: "7" })
dynamic_nodes[1] = Text(VText { value: " 7" })
dynamic_nodes[2] = Text(VText { value: "from expr" })
dynamic_attrs[0] = width Int(100)
dynamic_attrs[1] = hidden Bool(false)
dynamic_attrs[2] = lang Text("hi")
dynamic_attrs[3] = dir Text("rtl")
4 个动态属性,3 个动态节点。
class写了两次,两个都是字面量,merge_attributes合成静态的"a b",只占一个位置。"{{braces}}"、"{{not a hole}}"里的双花括号是转义,没有洞,是静态的;模板里存的是转义之后的{braces}。width: 100、hidden: false是整数、布尔字面量,不是字符串,只能动态,值是Int(100)、Bool(false)。"{GREETING}"引用的是const,宏不知道,照样是动态的Text("hi")。dir: if on { "rtl" }是if表达式,动态;on为false时它的值是AttributeValue::None,渲染器会删掉这个属性。"{x}"和"{x:>3}"都有洞,都是DynamicNode::Text;格式参数照常生效(" 7")。{"from expr"}是花括号表达式,哪怕里面是字面量也是动态的,&str的IntoDynNode把它变成Text。
小结
rsx!解析成CallBody,节点分六种BodyNode;根、组件 children、for循环体、if的每个分支各是一个模板,元素的子节点不另起模板。DynIdVisitor按深度优先给每个模板里的动态节点、动态属性各自从 0 编号,路径是从根开始的下标序列,第一个数是根的下标,发给渲染器时去掉。- 只有不含洞的字符串字面量是静态的;整数、布尔、
const、花括号表达式都是动态的;同名字面量属性合并后仍是静态。 - release 下每个模板是一个
static Template,路径是字面量;debug 下Template由HotReloadedTemplate::new在运行期从roots算出路径、每个调用点一次,每个洞还要多格式化一次,供热重载用。 - 空
rsx!变成一个占位符;key只取第一个根上的,必须是格式化字符串。