Skip to content

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 负责让原生控件树保持同步。

From XAML page to Reactor component — mental-model shift

思维模型的转变

可以把 Reactor 理解为"WinUI 控件,但像状态的函数那样来表达"。

XAML 中 Reactor 中
PageUserControlWindow 标记 Render()Component
{Binding Name} 直接使用普通 C# 变量:TextBlock(name)
Mode=TwoWay 受控输入:TextBox(name, setName)
DataContext 局部 Hook 状态、强类型 props 或上下文
ICommand Lambda、方法或命令
StackPanel VStackHStack
Grid.RowGrid.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);
    }
}

变化在于:

  • 绑定变成了状态变量。 NameEmailWantsUpdates 都存放在 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 对象: UseObservableUseObservableTree

这正是 Reactor 代码往往比 XAML 更简洁的原因。像 TextBlock($"{firstName} {lastName}") 这样的标签本身就已经"绑定"了——因为每当相关状态变化,Render() 就会重新执行。

注意: XAML 中 Mode=TwoWayBinding,在 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,都会清除此前设置的处理器。

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 会订阅你已有的视图模型,并在其变化时触发重新渲染。这让你可以逐个界面地迁移:

  1. 保留当前视图模型。
  2. 把 XAML 视图替换成 Reactor 组件。
  3. 之后如果愿意,再把简单的界面从视图模型状态迁到 Hook。

这通常是在既有代码库中采用 Reactor 时风险最低的方式。

你通常会停止编写的东西

大多数 XAML 开发者都会先注意到下面这些东西消失了:

  • 不再有 DataContext 管道——至少普通界面不需要了
  • 不再有价值转换器——用于简单格式化或显隐规则的那类
  • 不再有专门用来镜像控件状态的代码隐藏
  • 不再有独立的标记文件——用于常规的 UI 组合
  • 更少的迷你视图模型——那些唯一职责就是暴露可绑定属性的类型

替代品并不是"更多的框架",通常只是更多普通的 C#

一条务实的迁移路径

如果你要把一个已有的 WinUI 应用迁到 Reactor,下面这套顺序通常效果不错:

  1. 从一个叶子页面开始,不要一上来就动整个应用外壳。
  2. VStackHStackGridBorder 重建布局。
  3. UseState、props 或 UseObservableTree 替换绑定。
  4. 把琐碎的转换器内联为局部表达式。
  5. 保留既有服务与视图模型,直到 Reactor 版本稳定下来。
  6. 等页面跑通之后,再把可复用的 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 承载局部状态

NameEmail 等作为 INPC 属性暴露出来的小型"绑定视图模型"是 XAML 的惯用法——而在 Reactor 中,那几行会坍缩成几个 UseState 调用。INotifyPropertyChanged 的桥接依然存在(UseObservable),但只有在迁移既有视图模型时才该用它。新代码用 Hook。

还要写 XAML

Reactor 并不承载 XAML 加载器。没有 Application.LoadComponent 的等价物,没有 xmlns:reactor,也没有 *.reactor.xml 标记。如果应用确实需要把屏幕的一部分留在 XAML 中,那就用 WinUI 的 Page 作为宿主,再通过 winforms-interopReactorHostControl 嵌入 Reactor;该宿主内部的一切都是 C#。如果你的搜索结果里冒出某个外包人员的"Reactor XAML 编译器",那东西并不存在——框架的设计有意消除了第二门编写语言。

小贴士

想着"渲染当前的真相"。 在 XAML 中,你常常描述属性之间的关系;而在 Reactor 中,你通常直接算出当前值并返回它。

优先用状态,而不是控件引用。 如果某个改动应当刷新屏幕,就把值存进 UseState,而不是伸手去操作一个控件实例。

在原本用 UserControl 的地方改用小组件。 同样的拆分直觉依然适用;唯一的变化是那个可复用单元变成了 C# 组件,而不是 XAML 文件。

只在 MVVM 确实有价值的地方保留它。 既有的可观察对象迁移得很顺,但一旦你的 UI 本身就是 C#,许多小型的"仅用于绑定"的视图模型就不再必要了。

下一步

  • Reactor 与 XAML 的对比 — 架构长文:为什么绑定模型是根本性的不同,而不只是语法层面的不同
  • 快速上手 — 从零开始构建你的第一个 Reactor 应用
  • 组件 — 把 UI 拆成可复用的强类型组件
  • Hook — 学习 UseStateUseReducerUseEffect 以及核心渲染模型
  • 布局 — 把更多 WinUI 布局模式映射到 Reactor 原语
  • 高级模式 — 用 UseObservableTree 桥接既有的 MVVM 状态