Microsoft.UI.Reactor(以下简称 Reactor)"由状态渲染"的模型与 XAML 之间有一张清晰的 1:1 对照表,有经验的 XAML 开发者可以轻松记住。XAML 描述的是视图模型属性与控件依赖属性(DP)之间的绑定,并让绑定引擎在收到变更通知时去拉取值;而 Reactor 直接描述当前 UI,并在状态变化时重新求值整个组件。运行时的控件树仍是同一棵 WinUI 控件树——不同的只是编写方式。本页就是那把对照钥匙:你会用到的每一种 XAML 惯用法(DataContext、绑定模式、DataTemplate、DependencyProperty、代码隐藏、ICommand、Frame.Navigate)都对应到一种具体的 Reactor 写法,下文按顺序逐一讲解。配套长文 reactor-vs-xaml 讲述架构层面的为什么——读本页获取做法,读那一页获取理念。
面向 XAML 开发者的 Reactor¶
如果你已经熟悉 XAML,那么 Reactor 并不是另一套 Windows UI 技术栈。它渲染的仍然是真正的 WinUI 控件。变化在于你如何描述 UI:不再把一个页面拆散到 XAML、绑定、转换器与代码隐藏里,而是直接由 C# 返回 UI,再由 Reactor 负责让原生控件树保持同步。
思维模型的转变¶
可以把 Reactor 理解为"WinUI 控件,但像状态的函数那样来表达"。
| XAML 中 | Reactor 中 |
|---|---|
Page、UserControl、Window 标记 |
带 Render() 的 Component |
{Binding Name} |
直接使用普通 C# 变量:TextBlock(name) |
Mode=TwoWay |
受控输入:TextBox(name, setName) |
DataContext |
局部 Hook 状态、强类型 props 或上下文 |
ICommand |
Lambda、方法或命令 |
StackPanel |
VStack 或 HStack |
Grid.Row、Grid.Column |
.Grid(row: ..., column: ...) |
StaticResource / ThemeResource |
Theme 令牌与流畅修饰符 |
Frame.Navigate(...) |
UseNavigation + NavigationHost |
关键区别在于:Reactor 并不要求你描述绑定,它要求你描述当前 UI。状态变化时,Render() 重新执行,Reactor 只更新那些真正发生变化的原生 WinUI 控件。
重写一个熟悉的表单¶
下面这张 XAML 页面,是许多 WinUI 开发者的起点:
<StackPanel Spacing="12" Padding="24">
<TextBlock Text="Customer" FontSize="24" FontWeight="SemiBold" />
<TextBox Header="Name"
Text="{Binding Name, Mode=TwoWay}" />
<TextBox Header="Email"
Text="{Binding Email, Mode=TwoWay}" />
<CheckBox Content="Email me updates"
IsChecked="{Binding WantsUpdates, Mode=TwoWay}" />
<Button Content="Save"
Command="{Binding SaveCommand}" />
</StackPanel>
在 Reactor 中,同样的界面变成一个组件:
class TutorialFormPage : Component
{
public override Element Render()
{
var (name, setName) = UseState("");
var (email, setEmail) = UseState("");
var (wantsUpdates, setWantsUpdates) = UseState(true);
var canSave = !string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(email);
return VStack(12,
SubHeading("Customer"),
TextBox(name, setName, header: "Name"),
TextBox(email, setEmail, header: "Email"),
CheckBox(wantsUpdates, setWantsUpdates, label: "Email me updates"),
HStack(8,
Button("Save", () => { }).IsEnabled(canSave),
TextBlock(canSave ? "Ready to save" : "Complete all required fields")
.Opacity(0.7)
)
).Width(360);
}
}
变化在于:
- 绑定变成了状态变量。
Name、Email和WantsUpdates都存放在UseState里。 - 双向输入变成了显式的。
TextBox(name, setName)让数据流向一目了然。 - 命令变成了普通 C#。 保存按钮用一个 lambda,而不是 XAML 的命令接线。
- 派生 UI 保持内联。
canSave只是一个局部表达式,不需要转换器或额外属性。
一个界面就讲清了 Reactor 的模式:状态在上,返回的 UI 在下。
布局依旧熟悉,但更小¶
大多数 XAML 布局都能直接照搬,但 Reactor 会引导你使用更小的一组组合原语。
| XAML | Reactor |
|---|---|
StackPanel Orientation="Vertical" |
VStack(...) |
StackPanel Orientation="Horizontal" |
HStack(...) |
带行列定义的 Grid |
Grid(columns: ..., rows: ..., ...) |
Border |
Border(child) |
ScrollViewer(经典) |
ScrollViewer(child) |
ScrollView(现代) |
ScrollView(child) |
这段 XAML:
<Grid ColumnSpacing="12" RowSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="First name" />
<TextBox Grid.Row="0" Grid.Column="1" />
<TextBlock Grid.Row="1" Grid.Column="0" Text="Last name" />
<TextBox Grid.Row="1" Grid.Column="1" />
</Grid>
会变成:
class GridTranslationPage : Component
{
public override Element Render()
{
return Grid(
columns: [GridSize.Auto, GridSize.Star()],
rows: [GridSize.Auto, GridSize.Auto],
TextBlock("First name").Bold().Grid(row: 0, column: 0),
TextBox("", _ => { }).AutomationName("First name").Grid(row: 0, column: 1),
TextBlock("Last name").Bold().Grid(row: 1, column: 0),
TextBox("", _ => { }).AutomationName("Last name").Grid(row: 1, column: 1)
) with
{
ColumnSpacing = 12,
RowSpacing = 8
};
}
}
布局思想完全一样。区别在于子元素的位置由流畅修饰符表达,而不是写在标记里的附加属性。
绑定化作状态、props 或普通表达式¶
XAML 开发者常会去找 Reactor 中与 Binding 对应的东西。并没有一个唯一的替代品,因为绑定通常同时解决了好几个不同的问题。
请改用下面这条经验法则:
- 由本组件拥有的值:
UseState - 复杂的局部更新:
UseReducer - 来自父组件的输入: 通过
Component<TProps>的强类型 props - 计算得出的值: 普通的局部 C# 表达式
- 共享的应用状态:
上下文或更高层的父组件 - 已有的 MVVM 对象:
UseObservable或UseObservableTree
这正是 Reactor 代码往往比 XAML 更简洁的原因。像
TextBlock($"{firstName} {lastName}") 这样的标签本身就已经"绑定"了——因为每当相关状态变化,Render() 就会重新执行。
注意: XAML 中
Mode=TwoWay的Binding,在 Reactor 中对应的是受控输入模式(TextBox(name, setName)),而不是某种"带模式的绑定"。Reactor 没有Mode=OneWay/Mode=OneTime/Mode=TwoWay的对应物,因为状态本身就是绑定——每次渲染都从状态重新读取,每次编辑都调用 setter。如果你写的是TextBox(name, _ => { })且从不调用 setter,该字段就是只读的(等效于Mode=OneWay);如果两边都接上,它就会来回往返(等效于Mode=TwoWay)。要当心的陷阱是"为了 OneTime 去找绑定模式"——这里没有等价物。若你确实想要"捕获后冻结",就把值缓存进UseRef并读取ref.Current;或者在UseEffect(() => …, Array.Empty<object>())里调用一次函数,让它只在挂载时执行。Reactor 分析器不会为"缺少 setter"发出专门的诊断——向TextBox的变更处理器传null时,得到的是CS8625"无法将 null 字面量转换"。
事件与命令就是 C¶
你并不需要为每个按钮点击都准备一层特殊的命令机制。
Button("Save", Save)就是按钮命令的直接等价物。Button("Refresh", async () => await ReloadAsync())可用于异步操作。TextBox(text, setText)同时取代了TextChanged的接线与双向绑定。
如果你想要更丰富的忙碌/错误行为,Reactor 也提供了专门的 命令(Commanding) API。但默认方案被有意做得很小:先用方法或 lambda,等命令抽象真正带来价值时再引入。
事件:XAML 特性变成流畅方法¶
每一个 WinUI 事件特性都有对应的 Reactor 流畅方法。对大多数事件而言,规则很直接——把 Reactor 属性名开头的 On 去掉即可。少数 Reactor 流畅方法会对 WinUI 略有不同的事件形态做归一化(例如 CheckBox 在 XAML 中暴露了 Checked / Unchecked / Indeterminate 三个独立事件,而 Reactor 只提供一个 IsCheckedChanged 回调):
| WinUI XAML 事件 | Reactor 流畅方法 |
|---|---|
<Button Click="OnClick"/> |
Button("…").Click(handler) |
<TextBox TextChanged="OnTextChanged"/> |
TextBox(text, setText).Changed(handler) |
<ListView SelectionChanged="OnSelectionChanged"/> |
ListView<T>(...).SelectionChanged(handler) |
<ComboBox SelectionChanged="OnSelectionChanged"/> |
ComboBox(...).SelectedIndexChanged(handler) —— Reactor 报告的是选中索引,而不是事件参数 |
<CheckBox Checked="…" Unchecked="…"/> |
CheckBox(value, setValue).IsCheckedChanged(handler) —— Reactor 把 XAML 的三个事件收敛成一个 bool 回调 |
底层的 init 属性仍保留 On 前缀,因此既有的属性初始化代码依然可以编译:
class EventsFluentExample : Component
{
public override Element Render()
{
Action handler = () => { /* 点击处理 */ };
return VStack(8,
// 属性初始化依然可用:
new ButtonElement("Save") { OnClick = handler },
// 推荐的流畅写法:
Button("Save").Click(handler)
);
}
}
流畅方法之所以去掉 On,是因为 C# 会把 el.OnClick(arg) 绑定到"委托即属性"的调用形式(Action?.Invoke(arg)),而绝不会回退到扩展方法——相关发现与命名决策见
spec 039 第 0.1 节。向任何一个流畅方法传入 null,都会清除此前设置的处理器。
无需 Frame 的导航¶
Reactor 的导航在精神上仍是 WinUI 导航,但它声明在组件树里,而不是由命令式的 Frame.Navigate(...) 调用来驱动。
class TutorialNavigationPage : Component
{
public override Element Render()
{
var nav = UseNavigation(TutorialRoute.Home);
return Border(
NavigationView(
[
NavItem("Home", icon: "Home", tag: "Home"),
NavItem("Settings", icon: "Setting", tag: "Settings"),
NavItem("Account", icon: "Contact", tag: "Account")
],
content: NavigationHost(nav, route => route switch
{
TutorialRoute.Home => VStack(8,
Heading("Home"),
TextBlock("This is the shell root."),
Button("Go to Settings", () => nav.Navigate(TutorialRoute.Settings))
).Padding(24),
TutorialRoute.Settings => VStack(8,
Heading("Settings"),
TextBlock("Typed routes replace imperative Frame calls."),
Button("Back", () => nav.GoBack())
).Padding(24),
TutorialRoute.Account => VStack(8,
Heading("Account"),
TextBlock("A second page in the same shell.")
).Padding(24),
_ => TextBlock("Not found").Padding(24)
})
)
).Height(320).Background(Theme.CardBackground).CornerRadius(8);
}
}
你不再持有 Frame 引用并往里压入页面,而是在组件状态中持有一个强类型的导航句柄,再通过 NavigationHost 渲染当前页面。这让导航决策与 UI 的其他部分处在同一个声明式流程中。
迁移期间可以保留 MVVM¶
你不必在第一天就扔掉已有的 INotifyPropertyChanged 视图模型。Reactor 专门提供了一个用于迁移的桥接:
class ObservableTreeDemo : Component
{
private static readonly SettingsViewModel _vm = new();
public override Element Render()
{
var vm = UseObservableTree(_vm);
return VStack(12,
SubHeading("UseObservableTree"),
TextBox(vm.UserName, v => vm.UserName = v,
header: "User Name"),
ToggleSwitch(vm.DarkMode, v => vm.DarkMode = v,
header: "Dark Mode"),
Slider(vm.FontSize, 10, 32, v => vm.FontSize = (int)v)
.AutomationName("Font size"),
TextBlock($"Preview: {vm.UserName}")
.FontSize(vm.FontSize).Bold()
).Padding(24);
}
}
UseObservableTree 会订阅你已有的视图模型,并在其变化时触发重新渲染。这让你可以逐个界面地迁移:
- 保留当前视图模型。
- 把 XAML 视图替换成 Reactor 组件。
- 之后如果愿意,再把简单的界面从视图模型状态迁到 Hook。
这通常是在既有代码库中采用 Reactor 时风险最低的方式。
你通常会停止编写的东西¶
大多数 XAML 开发者都会先注意到下面这些东西消失了:
- 不再有
DataContext管道——至少普通界面不需要了 - 不再有价值转换器——用于简单格式化或显隐规则的那类
- 不再有专门用来镜像控件状态的代码隐藏
- 不再有独立的标记文件——用于常规的 UI 组合
- 更少的迷你视图模型——那些唯一职责就是暴露可绑定属性的类型
替代品并不是"更多的框架",通常只是更多普通的 C#。
一条务实的迁移路径¶
如果你要把一个已有的 WinUI 应用迁到 Reactor,下面这套顺序通常效果不错:
- 从一个叶子页面开始,不要一上来就动整个应用外壳。
- 用
VStack、HStack、Grid和Border重建布局。 - 用
UseState、props 或UseObservableTree替换绑定。 - 把琐碎的转换器内联为局部表达式。
- 保留既有服务与视图模型,直到 Reactor 版本稳定下来。
- 等页面跑通之后,再把可复用的 UI 抽成小组件。
目标不是"把 XAML 语法移植进 C#",而是在保留你 WinUI 知识的前提下,采纳 Reactor 那套由状态驱动的模型。
模式¶
依赖属性变成 Hook¶
在 XAML 中,控件上每一个响应式的值都挂在一个 DependencyProperty 上——一个全局注册的槽位,带有元数据、变更回调与继承规则。在 Reactor 中,与之对应的是 Render() 里的一次 Hook 调用:var (count, setCount) = UseState(0) 就相当于一个实例作用域的依赖属性,而它背后的 Hook 槽位表(hooks-internals)扮演的正是 XAML 中 DP 系统的角色。思维上的转变是"我不需要一个注册表,我需要一个按位置排列的槽位",而实现也随之大幅变小。
UserControl 变成组件¶
只要一个界面里出现带有自身状态、可复用的视觉块,XAML 开发者就会去用 UserControl。在 Reactor 中,那块东西就是一个组件——一个带 Render() 方法和强类型 props 的类。XAML 版本需要一个 .xaml 标记文件、一个 .xaml.cs 代码隐藏文件,通常还要有一个通过 DataContext 注入的属性;Reactor 版本就是一个 C# 类。复用的故事没变,代码行数在大多数控件上能减少约 70%。
DataTemplate 变成渲染函数¶
XAML 的 DataTemplate 声明列表中每一项如何渲染。Reactor 中的对应物是 ListView<T> /
GridView<T> / VirtualList 的第三个参数:一个返回每项元素树的
Func<T, Element>。项的选中与编辑是父组件里的状态,而不是手工接线的 SelectedItem 绑定。
recipes/master-detail 这个演练是标准范例。
常见错误¶
试图用 DependencyProperty 推导布局¶
Reactor 没有 DP 系统——没有 DependencyProperty.Register,没有元数据回调,也没有 AffectsMeasure。计算得出的值就是 Render() 里的普通局部变量;需要缓存的计算放在 UseMemo 里。如果你发现自己正在寻找"AffectsArrange 的等价物",答案是"你的 Render() 已经跑完,协调器也已经对影响布局的修饰符做了差异比对"——更详细的解释见
reactor-vs-xaml。
用 INotifyPropertyChanged 承载局部状态¶
把 Name、Email 等作为 INPC 属性暴露出来的小型"绑定视图模型"是 XAML 的惯用法——而在 Reactor 中,那几行会坍缩成几个 UseState 调用。INotifyPropertyChanged 的桥接依然存在(UseObservable),但只有在迁移既有视图模型时才该用它。新代码用 Hook。
还要写 XAML¶
Reactor 并不承载 XAML 加载器。没有 Application.LoadComponent 的等价物,没有 xmlns:reactor,也没有 *.reactor.xml 标记。如果应用确实需要把屏幕的一部分留在 XAML 中,那就用 WinUI 的 Page 作为宿主,再通过 winforms-interop 或
ReactorHostControl 嵌入 Reactor;该宿主内部的一切都是 C#。如果你的搜索结果里冒出某个外包人员的"Reactor XAML 编译器",那东西并不存在——框架的设计有意消除了第二门编写语言。
小贴士¶
想着"渲染当前的真相"。 在 XAML 中,你常常描述属性之间的关系;而在 Reactor 中,你通常直接算出当前值并返回它。
优先用状态,而不是控件引用。 如果某个改动应当刷新屏幕,就把值存进 UseState,而不是伸手去操作一个控件实例。
在原本用 UserControl 的地方改用小组件。 同样的拆分直觉依然适用;唯一的变化是那个可复用单元变成了 C# 组件,而不是 XAML 文件。
只在 MVVM 确实有价值的地方保留它。 既有的可观察对象迁移得很顺,但一旦你的 UI 本身就是 C#,许多小型的"仅用于绑定"的视图模型就不再必要了。