首页
/ Dioxus RSX 宏与 Autofmt 格式化器架构详解:从 JSX 风格语法到模板代码生成

Dioxus RSX 宏与 Autofmt 格式化器架构详解:从 JSX 风格语法到模板代码生成

2026-09-05 13:36:32作者:吴年前Myrtle

本文基于 Dioxus 仓库内的架构文档 notes/architecture/03-RSX.md,系统讲解 dioxus-rsx 宏如何把 JSX 风格的 RSX 语法解析为 Rust AST 并展开为模板代码,以及 dioxus-autofmt 如何对 RSX 块做无损格式化。读完本文,你可以理解 rsx! {} 宏内部的解析优先级、属性合并、动态索引分配与热重载映射机制,并能基于源码定位修改 RSX 语法、新增节点类型或调整格式化启发式的切入点。

一、两个 crate 的分工与整体流水线

Dioxus 的模板系统由两个 crate 协同完成(见 03-RSX.md 开头):

  • dioxus-rsx:解析 JSX 风格语法并生成 Rust 代码。入口类型是 CallBody,即 rsx! {} 宏的内容根节点。
  • dioxus-autofmt:提供格式化能力。它复用 dioxus-rsx 的解析结果(CallBody),把整个 rsx! 块重写为规范格式,再转换为 IDE 可精确应用的 FormattedBlock 编辑集合。

两者的衔接点在于:autofmt 并不自己解析语法,而是调用 CallBody::parse_strict 完成解析,再用自己的 Writer 按 AST 重新输出文本。解析一次,两处复用——这也是"新增节点类型需要同时改解析器和 Writer"这一扩展约束的由来。

二、RSX 解析入口:CallBody

CallBodyrsx! 宏内容的根结构,定义于 packages/rsx/src/rsx_call.rs

CallBody
├── body: TemplateBody        (BodyNode 根节点列表)
├── template_idx: Cell<usize> (模板索引计数器)
└── span: Option<Span>

其核心行为有三个关键点:

  1. Parse 实现委托给 CallBody::new:解析时先 input.parse::<TemplateBody>(),再走 new(),目的是"把热重载信息接线到嵌套结构中"(源码注释:Defer to the new method such that we can wire up hotreload information)。
  2. CallBody::new() 做三件事(见 rsx_call.rs):
    • 调用 body.split_oversized_templates() 预先切分超出存储上限的超大模板(见第五节);
    • 初始化 template_idx = 0 并给根模板分配第一个索引;
    • 调用 cascade_hotreload_info() 递归遍历所有节点,为每个嵌套的 TemplateBody(组件子体、for 循环体、if 链各分支)分配顺序模板索引。
  3. ToTokens 直接委托给 body.to_tokens(out)CallBody 本身不含渲染逻辑,只承载解析到的 token 信息。

BodyNode 枚举:RSX 内容的六种基本形态

BodyNode 定义于 packages/rsx/src/node.rs,架构文档列出六种变体:

  • Element(Element) — HTML 元素(div、span)
  • Component(Component) — 用户组件
  • Text(TextNode) — 带插值的字符串字面量
  • RawExpr(ExprNode) — 花括号表达式 {expr}
  • ForLoop(ForLoop)for pat in expr { body }
  • IfChain(IfChain)if cond { } else { }

需要注意,从当前源码结构看,实际还存在第七个变体 SyntheticBoundary(Box<TemplateBody>),它是宏在切分超大模板时自动插入的"动态边界"(见第五节),源码注释说明它是"包裹嵌套静态模板的动态边界"。这个变体不面向用户语法,纯粹是模板存储容量管理的产物。

解析优先级

BodyNode::parsenode.rs)按以下顺序 peek 决策:

  1. Peek LitStrTextNode
  2. Peek forForLoop
  3. Peek ifIfChain
  4. Peek matchRawExpr(match 语句没有特殊分支语法,整体作为表达式处理)
  5. Peek BraceRawExpr(花括号包裹的任意表达式)
  6. Web 组件:Ident 后紧跟 -Element(Web Components 不支持命名空间,直接按元素解析,如 my-cool-el {}
  7. 小写 ident 且不含下划线 → Element(下划线保留给组件名使用)
  8. 兜底 → Component

第 6、7 条是元素与组件区分的核心规则:div {} 是元素,Div {}crate::Div {} 是组件。源码中的测试 parsing_matches 逐条验证了上述每个分支的解析归类,包括 match 表达式落为 RawExprsome::cool::Component 落为 Component 等边界情况。

三、核心 AST 类型详解

Element 与属性合并

Element 结构(packages/rsx/src/element.rs):

Element
├── name: ElementName (Ident 或 Custom)
├── raw_attributes: Vec<Attribute>            // 原始解析结果
├── merged_attributes: Vec<Attribute>         // 重名属性合并后
├── spreads: Vec<Spread>                      // ..attr 展开
├── children: Vec<BodyNode>
├── brace: Option<Brace>
└── diagnostics: Diagnostics

两个设计细节值得展开:

  • 宽松解析:解析器非常宽容地解析元素——即使缺少花括号也不会解析失败,而是往 diagnostics 里推入 "Elements must be followed by braces" 诊断。这样宏可以在渲染时以诊断(而非编译错误)形式报出,保持"每个 CallBody 都应可构建"。
  • merge_attributes() 的重名合并element.rs):同名的多个属性会被折叠为一个。合并策略是构造 IfmtInput,段之间用空格作分隔符(源码 FIXME 注释说明这是刻意的特例,期望多行字符串能以空格合并)。规则包括:
    • key 类属性(name.is_likely_key())跳过合并;
    • 单个同名属性直接保留;
    • 字面量、if cond { "value" } 条件值都可以合并进格式化字符串;
    • 表达式、裸布尔等无法合并的类型会推入 "Cannot merge non-fmt literals" 诊断。

merge_all_attributes 测试 展示了完整效果:class: "foo" + class: "{bar}" + class: if true { "baz" } + class: if false { "{qux}" } else { "quux" } 四个属性合并为一个,展开为 ::std::format!("foo {0:} {1:} {2:}", bar, ...)。这解释了 Dioxus 中重复写 class 而非数组的经典用法。

ElementName 还有 Ident(Ident)Custom(LitStr) 两个变体:解析时把 ident - ident - ... 序列按 - 连接成 LitStr,这就是 some-cool-element 写法能工作的原因。

Attribute 与 AttributeValue

Attributepackages/rsx/src/attribute.rs):

Attribute
├── name: AttributeName (BuiltIn | Custom | Spread)
├── colon: Option<Token![:]>    // 为无损解析保留
├── value: AttributeValue
├── comma: Option<Token![,]>
└── el_name: Option<ElementName> // 绑定元素时用于属性名/命名空间解析

AttributeValue 的五种变体(attribute.rs):

  • Shorthand(Ident) — 无值属性。解析规则是"ident 后不跟冒号即 shorthand",例如 disabledchecked: checked(同名单 ident 表达式也可视为 shorthand);
  • AttrLiteral(HotLiteral) — 字面量,注释明确"这些获得热重载超能力";
  • EventTokens(PartialClosure) — 事件处理器。用专门类型是为了在闭包内提供自动补全(部分展开),并对 Rust 泛型闭包类型推断做额外包装。事件值以 move| 开头时走此分支;
  • IfExpr(IfAttributeValue) — 条件属性 attr: if cond { "a" } else { "b" }
  • AttrExpr(PartialExpr) — 任意表达式。

事件处理器还有一个值得注意的机制:event_handler_method() 会检测内联闭包,若是则改用隐藏的 onxxx_with_explicit_closure 方法,让闭包参数获得已知类型,用户无需手动标注(attribute.rs)。

HotLiteral:可热重载的字面量

HotLiteralpackages/rsx/src/literal.rs):

HotLiteral
├── Fmted(HotReloadFormattedSegment)  // "{expr}" 插值格式化字符串
├── Float(LitFloat)
├── Int(LitInt)
└── Bool(LitBool)

字符串字面量一律包装为 Fmted,因为需要区分"会生成 String 的格式化串"与"会生成 &'static str 的裸串"——这个区分对组件 props 的类型推导至关重要(源码注释原话)。is_static() 判断是否全为字面段,静态串可以直接输出为 &'static str,避免运行时格式化开销。

IfmtInput:格式化字符串的段解析

IfmtInputpackages/rsx/src/ifmt.rs)把字符串内容拆为段:

  • Segment::Literal(String) — 纯文本
  • Segment::Formatted(FormattedSegment){expr} 插值

解析规则(IfmtInput::from_rawifmt.rs)与文档一致并有细节补充:

  • {{ → 字面量 {}} → 字面量 }
  • {expr} → 格式化段
  • {expr:format_args} → 带格式说明符的段。解析器还专门处理了 :::两个连续冒号视为路径分隔符而非格式参数分隔符,因此 {path::expr} 这类写法可以正常工作
  • 孤立的 } 会报 "unmatched closing '}' in format string" 错误

ToTokens 生成时有三级降级策略(ifmt.rs):全静态直接输出 &str;release 模式下单插值优化为 expr.to_string()try_to_string);简单 ident 插值直接用 ::std::format!(raw) 享受 Rust 分析器的重命名展开。只有复杂表达式才走 FmtedSegments 动态池路径。

Component / ForLoop / IfChain

Component
├── name: syn::Path
├── generics: Option<AngleBracketedGenericArguments>
├── fields: Vec<Attribute>
├── component_literal_dyn_idx: Vec<DynIdx>
├── spreads: Vec<Spread>
├── children: TemplateBody
├── dyn_idx: DynIdx
└── diagnostics: Diagnostics

ForLoop
├── for_token, pat, in_token
├── expr: Box<Expr>
├── body: TemplateBody
└── dyn_idx: DynIdx

IfChain
├── if_token, cond: Box<Expr>
├── then_branch: TemplateBody
├── else_if_branch: Option<Box<IfChain>>
├── else_branch: Option<TemplateBody>
└── dyn_idx: DynIdx

for 循环在解析后改写为 (expr).into_iter().map(|pat| { body })(见 forloop.rs 的 ToTokens 与 node.rs 中 "Transform for loops into into_iter calls" 的注释)。if 链则把"未终止的 if 语句"转换为止于可选分支的终止形式。两者各自携带 TemplateBody,因此都会获得独立的模板索引(见 cascade_hotreload_info)。

DynIdx:热重载映射的索引

DynIdxCell<Option<usize>>,用于追踪动态节点/属性的索引。它刻意对 PartialEq/Eq/Hash 保持"透明"(比较时忽略内部值),这样含 DynIdx 的 AST 节点仍可做相等性比较。索引与 file!()line!()column!() 组合,构成每个模板在热重载系统中的全局定位。

四、代码生成:从 TemplateBody 到模板 VNode

文档描述的经典输出结构

架构文档给出的 rsx! 展开产物结构是:

  1. __TEMPLATE_ROOTSTemplateNode 静态数组
  2. 动态节点数组 __dynamic_nodes: [DynamicNode; N] — 组件、插值文本、循环、条件
  3. 动态属性数组 __dynamic_attributes — 非常量属性值
  4. 动态字面量池 — debug 模式下格式化后的字面量 vec
  5. 动态值池 — 把字面量索引映射到运行值
dioxus_core::Element::Ok({
    #[cfg(debug_assertions)]
    fn __original_template() -> &'static HotReloadedTemplate { ... }

    let __dynamic_nodes: [DynamicNode; N] = [ ... ];
    let __dynamic_attributes: [Box<[Attribute]>; M] = [ ... ];
    static __TEMPLATE_ROOTS: &[TemplateNode] = &[ ... ];
    // Template 引用与渲染
})

生成的 TemplateNode 有三种形态:Element { tag, namespace, attrs, children }(静态元素)、Text { text }(静态文本)、Dynamic { id }(引用动态节点池)。

当前源码中的实现演进

packages/rsx/src/template_body.rs 的当前实现看,代码生成已经从"直接生成节点数组"演进为类型化 View 构建器(ViewBuilder)ToTokens for TemplateBody 先调用 self.normalized(),再由 ViewBuilderPieces::from_body(&node) 单次遍历同时产出——release 用的类型化 view 表达式、模板容量统计(TemplateStatsBuilder)、debug 用的热重载表。关键的 ToTokens 展开结构(template_body.rs):

  • release 路径dioxus_core::view::into_vnode_with_capacity::<OPS, STRINGS, DYNAMICS, _>(__view) 直接以编译期预测的容量构建 VNode,模板是跨热重载稳定的 const &'static Template
  • debug 路径static __RUNTIME_TEMPLATE: OnceLock<Template> 缓存运行时降级的模板,"避免每处 const 求值拖慢编译,同时产生完全相同的模板"(源码注释原话);并通过 GlobalSignal::with_location(|| None, file, line, column, template_idx) 建立热重载槽位——key 由归一化后的文件路径、行列号和模板索引组成。若读不到热重载后的模板,则回退到 __original_template,注释特别指出"宏内嵌套模板可能因相同的 file-line-column-index 被合并,无法热重载,回退可防止错误渲染";
  • 动态字面量池 __dynamic_literal_pool 与动态值池 DynamicValuePool::from_vnode(...).render_with(__template_read) 保留了文档描述的"字面量池 + 值池"两层结构,用于 debug 下替换模板中的动态段。

ViewBuilder 的遍历规则(template_body.rs)与文档的动态节点划分一一对应:静态文本走 StaticTextBuilder(生成 impl dioxus_core::view::StaticText { const TEXT: &'static str } 标记结构体);含插值的文本、ComponentRawExprForLoopIfChainSyntheticBoundary 一律经 dynamic_node() 分配递增 id 并注册 HotReloadDynamicNode::Dynamic(id)。动态属性则经 track_dynamic_attr 注册 HotReloadDynamicAttribute::Dynamic(id),其内嵌格式化字面量会同步进入动态文本池,保证"属性、key、子节点"的填充顺序与运行时池严格对齐(源码有专门注释说明 key 段必须先行分配)。

模板 ID 分配与热重载定位

  • CallBody::next_template_idx() 生成顺序 ID,每个嵌套结构(组件子体、循环体、if 各分支、合成边界)各得唯一 ID;
  • 结合 file!()line!()column!() 组成源位置,与 GlobalSignal 的 key 一起实现"改哪处 rsx 就重载哪个模板"。

HotReloadFormattedSegment

它包裹 IfmtInput 并维护 dynamic_node_indexes:每个 Segment::Formatted 对应一个动态节点 id。allocate_formatted()template_body.rs)为格式化段分配动态文本池索引,再由 quote_with_dynamic_ids() 生成 FmtSegment::Dynamic { id } 序列——这就是"格式化段 → 动态节点"映射的落地点。

超大模板切分

split_oversized_templates()template_body.rs)在 CallBody::new 阶段运行:当某层兄弟节点的路径位数超过 TEMPLATE_SLOT_PATH_MAX_PATH_BITS(127,测试 path_bit_split_limit_matches_slot_path_payload_capacity 验证了该边界)、或 ops/strings 超过 TEMPLATE_STORAGE_MAX_CAP、动态节点/属性数超过 u16::MAX 时,把节点列表对半切分为多个 TemplateBody,包成 BodyNode::SyntheticBoundary。展开时它经 dioxus_core::IntoDynNode::into_dyn_node(...) 转成动态节点——用户语法不受影响,存储容量约束被宏静默消化。

五、Autofmt 格式化系统

入口 API

dioxus-autofmt 的三个入口(packages/autofmt/src/lib.rs):

  • try_fmt_file(contents, &syn::File, IndentOptions) -> syn::Result<Vec<FormattedBlock>> — 格式化完整文件,返回供 IDE 应用的块级编辑集合;
  • fmt_block(block_str, indent_level, IndentOptions) -> Option<String> — 格式化单个 rsx! 块;
  • write_block_out(body: &CallBody) -> Option<String> — 把已解析的 CallBody 写回字符串。

旧的 fmt_file 已被标记 #[deprecated]("错误时会 panic,请用 try_fmt_file")。FormattedBlock 携带 formatted 新内容、start/end 字节偏移,专为 VSCode 的 TextEdit API 定制;文档注释坦承"目前按整个 rsx! 块重写,而非逐行精确修改",API 设计保留向更精确编辑方式迁移的空间。

try_fmt_file 的工作流程

  1. collect_from_file(parsed) 收集文件中所有 rsx! 宏调用;
  2. 逐个解析(CallBody::parse_strict,宏体内有错误立即返回),跳过已被外层宏覆盖的内层宏(按 span 比较);
  3. Writer::new(contents, indent) 写出格式化文本,并把 Writer 缩进对齐到宏所在行的实际缩进(count_indents);
  4. 短块短路优化lib.rs):
// 若格式化后 <= 80 字符、无换行、不是单一表达式、非空,则折成单行
if formatted.len() <= 80
    && !formatted.contains('\n')
    && !body_is_solo_expr
    && !formatted.trim().is_empty()
{
    formatted = format!(" {formatted} ");  // 折叠为 div { ... } 单行形式
}

body_is_solo_expr 特例:单根节点为 RawExpr/Text 时不折叠——源码注释解释这是为了保持 rustfmt 与宏格式化的边界(rustfmt 会处理"宏 + 单表达式"的空格,若 autofmt 也折叠会互相干扰); 5. 与原文逐字节比较,相同则跳过,不同则追加 FormattedBlock

apply_formats() 按 start/end 拼接所有块,得到最终文件内容。

Writer 与 Buffer 状态

Writer
├── raw_src: &str
├── src: Vec<&str>                    // 按行切分的原文件
├── cached_formats: HashMap<LineColumn, String>  // 表达式格式化缓存
├── out: Buffer
└── invalid_exprs: Vec<Span>          // 记录无法格式化的不完整表达式

Buffer
├── buf: String
├── indent_level: usize
└── indent: IndentOptions

invalid_exprs 的存在对应一个实用约束:try_fmt_file 对"不完整表达式"会提前返回错误,注释说明"虽然我们可以返回部分表达式,但最终表达式格式化会交给 rustfmt,而 rustfmt 会拒绝不完整代码"——即 rsx! 内写了半个闭包时,保存动作会得到明确错误而不是坏输出。

四级优化级别

Writer::write_rsx_blockpackages/autofmt/src/writer.rs)内部定义了 ShortOptimization 枚举,与文档的四级一致:

  1. Emptydiv {}(花括号内不加空格,直接输出 });
  2. Onelinerdiv { class: "x", child {} }(属性与子节点全在一行,用空格分隔);
  3. PropsOnTop:属性保持在首行,子节点换行缩进排列;
  4. NoOpt:一切多行——每个属性独占一行并缩进。

决策规则从源码可见:属性列表"短"的定义是 is_short_attrs 累加长度 + 当前缩进 ×4 < 80,且最多 3 个属性(超过直接返回哨兵值 100000);子节点短小判定叠加在 100 字符预算内(children_len + attr_len + indent_level * 4 < 100);此外若属性超长(attr_len > 1000,即包含换行/注释的哨兵值)或 split_line_attributes() 开启,强制降级为 NoOpt。

空白与注释保留

RSX 中空白是显著的:文本节点保留精确空白、注释必须保留、格式化不得改变文本节点内容。源码里对应的实现:

  • accumulate_full_line_comments()writer.rs):从节点 span 起点向上回溯收集整行 // 注释(最多保留一个空行),写入时随节点一起输出,保证节点上方的注释块不丢、位置跟随节点移动;
  • write_inline_comments() 保留行尾注释,通过 LineColumn(来自 Span)定位当前行剩余内容,仅当以 // 开头时才追加;
  • write_attr_comments() 只处理"注释属于属性自身行"的情况——比较属性 span 与左花括号是否同行,避免把上一行末尾的注释错误归属。

表达式格式化与 Marker 替换

write_partial_expr() 的策略(文档 + writer.rs):

  1. 用 vendored 的 prettier_please 对表达式做 unparse;
  2. 特殊处理嵌套的 rsx! 宏:prettier_please 不认识 rsx! 语法,所以 unparse 前把宏路径替换为 marker Unicode 字符串 "𝕣𝕤𝕩"packages/autofmt/src/prettier_please.rsconst MARKER: &str = "𝕣𝕤𝕩"),用 rsx 宏自身格式化结果替换回 marker,避免与真实代码冲突;
  3. 与源文本逐行比对:源中的行注释(正则 "[^"]*|(//.*) 区分字符串字面量与注释)会被重新注入 pretty 输出,保证表达式内的注释不丢失。

缩进系统

IndentOptionspackages/autofmt/src/indent.rs):

IndentOptions
├── width: usize
├── indent_string: String   ("\t" 或若干空格)
└── split_line_attributes: bool
  • IndentOptions::new(IndentType, width, split_line_attributes) 构造,width == 0 直接断言失败;Tabs"\t"Spaceswidth 个空格;
  • 默认值是 4 空格、不拆行属性IndentType::Spaces, 4, false);
  • line_length() 把 tab 按 width 计宽,count_indents() 对 tab 和整组空格都计数(indent.rs),测试覆盖了 tab 与空格混排的场景;
  • split_line_attributes: true 时所有属性强制独占一行(对应优化级别被压到 NoOpt)。

属性格式化

  • write_attributes(props_same_line)true 时属性一行排列、逗号后跟空格;false 时每属性换行缩进,且属性间允许插入行尾注释;
  • write_attribute_value() 的分发(writer.rs):
    • Shorthand → 只写 ident;
    • AttrLiteral → 用 Display 实现直接输出(HotLiteral::fmt 对字符串走 to_string_with_quotes(),保留转义与引号);
    • EventTokenswrite_partial_expr(),缩进层级 +1;
    • IfExprwrite_attribute_if_chain(),按"格式化长度 ≤ 80 − 当前缩进×4"的预算决定内联(if cond { "a" } else { "b" } 一行写完)还是多行展开;
    • AttrExprwrite_partial_expr()

六、扩展点

架构文档给出的三条扩展路径,与源码结构完全对应:

新增节点类型

  1. BodyNode 枚举加变体(node.rs);
  2. 实现 Parse trait 并在 BodyNode::parse 的优先级链中插入 peek 分支;
  3. 实现 ToTokens 完成代码生成(ViewBuilder 的 visit_node 也要覆盖新分支);
  4. 在 autofmt 的 Writer::write_identwriter.rs)中加对应 write_* 方法——SyntheticBoundarywrite_synthetic_boundary 就是一个现成例子。

新增属性值类型

  1. AttributeValue 加变体(attribute.rs);
  2. AttributeValue::parse 中实现探测(如 ifIfExprmove/|EventTokens 的先例);
  3. 视需要在 Element::merge_attributes 中加合并处理或诊断;
  4. Writer::write_attribute_value 加分发分支。

调整格式化启发式

  • 修改 ShortOptimization 的判定逻辑与降级条件;
  • 调整阈值常量:属性短判定 80、Oneliner 总预算 100、整块短路 80、单属性超长按 1000/哨兵 100000;
  • 修改 attr_value_len / is_short_attrs 等长度估算函数(注意含注释或换行的表达式统一按 100000 处理,即强制多行)。

七、小结与源码索引

主题 关键文件
宏入口与模板索引级联 packages/rsx/src/rsx_call.rs
BodyNode 解析优先级 packages/rsx/src/node.rs
元素解析与属性合并 packages/rsx/src/element.rs
属性与属性值类型 packages/rsx/src/attribute.rs
热重载字面量 packages/rsx/src/literal.rs
格式化字符串段解析 packages/rsx/src/ifmt.rs
模板降级与代码生成 packages/rsx/src/template_body.rs
循环 / 条件节点 packages/rsx/src/forloop.rspackages/rsx/src/ifchain.rs
格式化入口与块编辑 packages/autofmt/src/lib.rs
Writer 与优化级别 packages/autofmt/src/writer.rs
缩进选项 packages/autofmt/src/indent.rs
Marker 替换 packages/autofmt/src/prettier_please.rs

整套设计的主线是"解析一次,多处复用":dioxus-rsx 产出的 CallBody 既是 rsx! 宏展开的输入,也是 autofmt 重写的依据,还是热重载索引级联的载体。理解 template_idx/DynIdx 如何把"AST 位置"编码为"模板池下标 + 源位置",是把握 Dioxus 热重载与模板存储机制的钥匙;而 autofmt 的四级优化与 80/100 字符预算则解释了你在 IDE 中看到的每一种 rsx 排版形态从何而来。

登录后查看全文
热门项目推荐
相关项目推荐