Skip to content

Microsoft.UI.Reactor(以下简称 Reactor)应用是一棵由 Hook 驱动的组件树,托管在框架为你打开并管理的 WinUI 窗口中。你只需写一个 C# 文件、调用 ReactorApp.Run<T>,组件的 Render() 方法返回的元素树就会被转化为原生控件树。状态保存在 UseState 等 Hook 中;每调用一次 setter 就会重新执行 Render();协调器将新树与上一棵树做差异比对,并就地对 WinUI 控件打补丁。本页是入门引导——安装框架、生成项目骨架,并从 hello-world 一路长成一个待办清单和一个计算器。读完之后,你将亲手跑过代码、看到每一步的截图,并认识后续文档会详细展开的布局原语与 Hook

Reactor 快速上手

前置条件: .NET 10+ 与 Windows 应用 SDK(Windows App SDK)。

公共预览版包已发布。 Reactor 在 NuGet.org 上提供 Microsoft.UI.Reactor 0.1.0-preview.15。项目模板包目前仍需从源码安装;bootstrap.ps1 会安装 mur、 打包并注册本地的 reactorapp 模板,并让生成的应用默认引用公共预览版包。 更广泛的签名分发计划见 spec 022

Reactor 是一个声明式 UI 框架,用于以纯 C# 构建原生 Windows 应用。没有 XAML,没有数据绑定,没有视图模型。你把 UI 描述成状态的函数,Reactor 负责让屏幕与状态保持同步。

环境准备(只需一次)

git clone https://github.com/microsoft/microsoft-ui-reactor.git
cd microsoft-ui-reactor
./bootstrap.ps1

就这些。bootstrap.ps1 会把 mur 打包并安装为 dotnet tool 全局安装(因此它跨 shell 都在 PATH 上,无需手动修改 $env:Path),接着运行 mur pack-local 生成本地源码构建的框架快照包以及配套的 ProjectTemplates nupkg,注册 dotnet new reactorapp 模板,并把 Reactor 智能体插件放到 ~/.claude/plugins/reactor(允许时创建符号链接,否则复制)。由该模板创建的应用默认从 NuGet.org 引用版本 0.1.0-preview.15Microsoft.UI.Reactor

脚本执行完毕后,你可以立刻运行:

dotnet new reactorapp -n MyApp
cd MyApp
dotnet run

执行 git pull 之后

源码检出会变——但你的本地模板包、CLI、插件以及可选的源码构建框架快照不会跟着变,除非你重新打包。两种选择:

mur upgrade           # 重新打包框架与模板,并刷新插件
./bootstrap.ps1       # 同上,另外还会更新 `mur` 全局工具本身

mur upgrade 是轻量路径。当你想获取 CLI 本身的改动时,重新运行 bootstrap.ps1(一个正在运行的 mur 进程无法在运行途中替换自己的二进制文件)。

验证安装

mur doctor

它会列出本指南后续所有内容所依赖的每一项——.NET 10+ SDK、PATH 上的 murlocal-nupkgs/ 开发者源、reactorapp 模板注册状态,以及可选的 Claude 插件。每一行都给出 PASS / WARN / FAIL,并为异常项附上一行修复建议。

这套流程能给你什么。 一个全局可解析的 mur(经由 ~/.dotnet/tools)、一个本地安装的 reactorapp 模板(其引用为 <PackageReference Include="Microsoft.UI.Reactor" Version="0.1.0-preview.15" />)、 位于 <repo>/local-nupkgs/ 的本地 NuGet 源(用于源码构建的冒烟测试), 以及一个智能体插件,让 AI 助手能基于真实的工厂方法生成代码 (mur --skill / mur --api 打印同样的内容)。每当你拉到新的模板、CLI、插件或框架改动时,运行一次 mur upgrade

只需要框架包? 直接从 NuGet.org 引用已发布的 Microsoft.UI.Reactor 包即可。只有当你想要本源码检出中的本地项目模板、 mur CLI 或智能体插件时,才需要运行 bootstrap。

手动配置

如果你不想运行 bootstrap.ps1,下面是它所做的每一步的准确还原。每条命令都是标准的 dotnetgit 调用——直到最后一步才需要 Reactor 自身的工具:从源码构建 mur,并把它安装为全局工具。下面每个代码块都对应 bootstrap.ps1 中的一个编号阶段,因此出问题时,脚本本身就是很好的对照参考。

# 步骤 产出
1 dotnet --list-sdks 确认已安装 .NET 10+
2 git clone + cd 本地源码检出
3 dotnet pack src/Reactor.Cli local-nupkgs/ 下的 Microsoft.UI.Reactor.Cli.<ver>.nupkg
4 dotnet tool install -g mur 可从 ~/.dotnet/tools 跨 shell 解析
5 mur pack-local 源码构建的框架快照,外加本地 ProjectTemplates nupkg;生成的应用默认使用公共 Reactor 预览版
6 dotnet new uninstall + install 注册 dotnet new reactorapp 模板
7 符号链接/复制 plugins/reactor ~/.claude/plugins/reactor 下的 Reactor 智能体套件(可选)
8 mur doctor 验证第 1–7 步全部生效

1. 确认前置条件。 Reactor 需要 .NET 10 或更高版本。Windows 应用 SDK 会在框架构建时以传递依赖的方式引入。

dotnet --list-sdks
# 预期至少有一条以 "10." 开头的记录

2. 克隆并进入仓库。 以下所有步骤都假定工作目录为仓库根目录。

git clone https://github.com/microsoft/microsoft-ui-reactor.git
cd microsoft-ui-reactor

3. 把 mur 打包成全局工具 nupkg。 Reactor.Cli.csproj 设置了 PackAsTool=true,因此 dotnet pack 会产出一个工具包。 -p:Platform 参数很关键,因为构建步骤会运行 SignaturesGen apphost 来刷新 skills/reactor.api.txt,而该 apphost 必须与宿主架构一致。

$hostArch = if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { 'ARM64' } else { 'x64' }
dotnet pack src/Reactor.Cli/Reactor.Cli.csproj `
    -c Release `
    "-p:Platform=$hostArch" `
    -o local-nupkgs `
    --nologo
# 产出 local-nupkgs/Microsoft.UI.Reactor.Cli.<version>.nupkg

4. 把 mur 安装为 dotnet 全局工具。 正是这一步让 mur 跨 shell 都出现在 PATH 上,无需修改 $env:Path。如果之前已安装过,请用 update 代替 install

dotnet tool install -g `
    --add-source ./local-nupkgs `
    Microsoft.UI.Reactor.Cli `
    --no-cache --ignore-failed-sources

# 若此前已安装过 `mur`:
# dotnet tool update -g --add-source ./local-nupkgs Microsoft.UI.Reactor.Cli --no-cache --ignore-failed-sources

dotnet tool install -g 会把 ~/.dotnet/tools 加到用户级 PATH,而当前 shell 不会自动继承这一变更。仅在当前会话中手动前置它,好让下一步能找到 mur

$env:Path = "$env:USERPROFILE\.dotnet\tools;$env:Path"

新开的 PowerShell 窗口会自行获取用户级 PATH 的变更。

5. 打包本地框架快照与项目模板。 这一步会产出用于冒烟测试的源码构建 0.0.0-local 框架 nupkg,以及安装 dotnet new reactorapp 的本地 ProjectTemplates nupkg。模板的正常默认值引用公共的 Microsoft.UI.Reactor 0.1.0-preview.15 包。

mur pack-local
# 产出:
#   local-nupkgs/Microsoft.UI.Reactor.0.0.0-local.nupkg
#   local-nupkgs/Microsoft.UI.Reactor.Advanced.0.0.0-local.nupkg
#   local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg

如果你不想依赖刚安装好的 mur,也可以直接调用源码项目:

dotnet run --project src/Reactor.Cli/Reactor.Cli.csproj `
    -c Release "-p:Platform=$hostArch" -- pack-local

6. 安装 dotnet new reactorapp 模板。 模板引擎按包 id 做缓存,因此同版本重新打包可能会败给缓存副本。务必先卸载。

dotnet new uninstall Microsoft.UI.Reactor.ProjectTemplates 2>$null
dotnet new install local-nupkgs/Microsoft.UI.Reactor.ProjectTemplates.0.0.0-local.nupkg

7.(可选)安装 Reactor 智能体插件。 如果你使用 Claude Code 或其他智能体,并希望它用正确的工厂方法编写 Reactor 代码,就把仓库内的插件目录放进该智能体的插件路径。首选符号链接,这样检出中的改动能立刻生效;当无法创建符号链接时(未开启开发者模式且非管理员 shell),复制同样可用。

$pluginSrc = (Resolve-Path "plugins/reactor").Path
$pluginDst = "$env:USERPROFILE\.claude\plugins\reactor"
New-Item -ItemType Directory -Path (Split-Path $pluginDst) -Force | Out-Null
if (Test-Path $pluginDst) { Remove-Item $pluginDst -Recurse -Force }
try {
    New-Item -ItemType SymbolicLink -Path $pluginDst -Target $pluginSrc -ErrorAction Stop | Out-Null
} catch {
    Copy-Item $pluginSrc $pluginDst -Recurse -Force
}

对于 Copilot CLI 或其他智能体,请遵循各自工具的插件安装方式,并指向 <repo>/plugins/reactor

8. 验证。 mur doctor 执行的检查与 bootstrap 脚本最后阶段所依赖的一致——SDK 版本、mur 可解析性、本地开发者 nupkg 是否存在、模板是否已注册、插件是否已安装。

mur doctor

执行 git pull 后的刷新

不使用 bootstrap 脚本时,每次 pull 之后请重复第 5 和第 6 步——框架 nupkg 与模板都需要针对新源码重新生成。只有当 src/Reactor.Cli/ 自身发生变化时,才需要重复第 3 和第 4 步(正在运行的 mur 进程无法替换自身的二进制文件,因此安装必须从另一个没有在运行 mur 的 shell 中执行)。

为什么用全局工具,而不是直接从 bin/<arch>/ 运行? 两者都可以。仓库的 CLI csproj 在每次构建后仍会把 mur.exe 镜像到 bin/<arch>/,以兼容旧的 PATH 布局。全局工具安装只是最友好的默认选项——它让 mur 跨 shell、跨工作目录都出现在 PATH 上,无需按架构折腾 PATH,而且 dotnet tool update -g 就自然成为升级动作。

注意: 核心框架包已公开发布,但 reactorapp 项目模板包仍需从源码安装。若 dotnet new reactorapp 缺失,请运行 bootstrap.ps1(或在已 bootstrap 的检出中运行 mur upgrade),从 local-nupkgs/ 重新打包并安装 Microsoft.UI.Reactor.ProjectTemplates。 模板安装器按包 id 缓存,因此同版本重新打包可能败给缓存副本——mur upgrade 会先执行 dotnet new uninstall 来处理这一点。

创建项目

模板装好之后,可以在磁盘任意位置生成新应用:

dotnet new reactorapp -n MyApp
cd MyApp
dotnet run

模板会配好 Microsoft.UI.Reactor 包引用、WinUI 3 目标框架,以及一个可运行、挂载了单个 Reactor 组件的 App.cs。没有 App.xaml,没有 MainWindow.xaml.cs——只有一个 C# 文件。

默认情况下该包引用为 <PackageReference Include="Microsoft.UI.Reactor" Version="0.1.0-preview.15" />。 若要进行本地框架冒烟测试,请用 dotnet new reactorapp -n MyLocalApp --MSUIReactorVersion 0.0.0-local 生成,并在源码检出内(或另一个已配置本地源的目录)运行。

为什么需要自定义模板? dotnet new console 并不能生成 WinUI 应用——它构建的是控制台目标:没有 UI 线程、没有 OutputType=WinExe、 没有 WindowsAppSDK 引用、也没有 [STAThread] 入口点。reactorapp 则把这一切都设好,外加 Reactor 包引用和一个可感知背景材质的根组件, 所以你第一次 dotnet run 就能看到窗口,而不是一个控制台宿主的空壳。

第一个应用

模板生成的 App.cs 就是标准的 hello-world。把它替换为下面的代码片段,以便与本篇后续内容保持一致(模板默认的起始代码略丰富一些,而这个更简单的形式更便于讲解):

using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using static Microsoft.UI.Reactor.Factories;
using Microsoft.UI.Xaml;

ReactorApp.Run<GettingStartedApp>("Getting Started", width: 600, height: 400);

class GettingStartedApp : Component
{
    public override Element Render()
    {
        var (name, setName) = UseState("World");

        return VStack(16,
            TextBlock($"Hello, {name}!").FontSize(24).Bold(),
            TextBox(name, setName, placeholderText: "Enter your name")
                .AutomationName("Name")
                .Width(250)
        ).Padding(24);
    }
}

dotnet run 运行它,你会看到:

Hello World app running

其中发生的事情是:

  • ReactorApp.Run<T> 启动一个窗口并挂载你的根组件。
  • 开发工具支持由应用项目的 Reactor.DevtoolsSupport 开关启用,而不是由 Run 的参数控制。文档示例应用从 docs/_pipeline/apps/Directory.Build.props 继承了该开关,因此截图流水线可以用 --devtools 启动它们;真实应用通常把该开关限制为仅 Debug,以便 Release 构建保持精简。
  • UseState 返回当前值与一个 setter。调用 setter 时,Reactor 会用新值重新渲染该组件。
  • VStack 纵向堆叠子元素。数字 16 是像素间距。
  • TextBlock(...).FontSize(24).Bold() 是流畅的修饰符模式——每个元素都支持可链式调用的修饰符,用于样式与布局。

在文本框里输入内容,问候语会即时更新。没有任何事件接线,也没有属性通知——状态进,UI 出。

理解状态

任何交互式 UI 都需要状态。在 Reactor 中,UseState 是管理随时间变化的值的主要 Hook。

计数器示例

下面是一个追踪单个数字的计数器:

// 启动方式:
//   ReactorApp.Run<CounterExample>("Counter", width: 600, height: 400);

class CounterExample : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);

        return VStack(12,
            TextBlock($"Count: {count}").FontSize(20).SemiBold(),
            HStack(8,
                Button("- 1", () => setCount(count - 1)),
                Button("Reset", () => setCount(0)),
                Button("+ 1", () => setCount(count + 1))
            )
        ).Padding(24);
    }
}

Counter with buttons

每次调用 setCount 都会触发一次重新渲染。Reactor 对新旧元素树做差异比对,只更新那些真正发生变化的 WinUI 控件。

多个状态值

组件可以多次调用 UseState——每次调用都追踪一个彼此独立的值:

// 启动方式:
//   ReactorApp.Run<MultipleStateExample>("Multiple State", width: 600, height: 400);

class MultipleStateExample : Component
{
    public override Element Render()
    {
        var (firstName, setFirstName) = UseState("");
        var (lastName, setLastName) = UseState("");
        var (fontSize, setFontSize) = UseState(16.0);

        var fullName = string.IsNullOrWhiteSpace(firstName) && string.IsNullOrWhiteSpace(lastName)
            ? "Anonymous"
            : $"{firstName} {lastName}".Trim();

        return VStack(12,
            TextBlock($"Hello, {fullName}!").FontSize(fontSize).Bold(),
            TextBox(firstName, setFirstName, placeholderText: "First name")
                .AutomationName("First name")
                .Width(200),
            TextBox(lastName, setLastName, placeholderText: "Last name")
                .AutomationName("Last name")
                .Width(200),
            HStack(8,
                TextBlock("Font size:"),
                Slider(fontSize, 10, 40, setFontSize).Width(200),
                TextBlock($"{fontSize:F0}px")
            )
        ).Padding(24);
    }
}

fullName 变量在每次渲染时由 firstNamelastName 推导得出。在 Reactor 中,你不需要计算属性或绑定——普通的 C# 表达式就够了,因为 Render() 会在每次状态变化时重新执行。

布局基础

Reactor 提供了一小组可自由组合的布局原语:

// 启动方式:
//   ReactorApp.Run<LayoutBasicsExample>("Layout", width: 600, height: 400);

class LayoutBasicsExample : Component
{
    public override Element Render()
    {
        return VStack(16,
            Heading("Layout Demo"),

            SubHeading("Horizontal Stack"),
            HStack(8,
                Button("One"),
                Button("Two"),
                Button("Three")
            ),

            SubHeading("Nested Layout"),
            HStack(16,
                VStack(4,
                    TextBlock("Left Column").Bold(),
                    TextBlock("Item A"),
                    TextBlock("Item B")
                ),
                VStack(4,
                    TextBlock("Right Column").Bold(),
                    TextBlock("Item X"),
                    TextBlock("Item Y")
                )
            )
        ).Padding(24);
    }
}

Layout demo

元素 用途
VStack 纵向堆叠(子元素自上而下)
HStack 横向堆叠(子元素自左而右)
Grid 支持比例尺寸的行/列网格
ScrollView 为溢出内容提供滚动包裹容器
Border 带背景、圆角与描边的容器

所有布局元素的第一个参数都是可选的间距参数:VStack(12, child1, child2) 会在子元素之间加入 12px 间距。

构建一个待办应用

下面把这些零件拼成一个真正的应用。待办应用需要一份条目列表、一种新增条目的方式,以及用于标记完成的复选框。

首先,为条目定义一个简单的 record:

record TodoItem(string Id, string Text, bool Done);

然后是完整组件:

using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using static Microsoft.UI.Reactor.Factories;
using Microsoft.UI.Xaml;

ReactorApp.Run<TodoApp>("Todo App", width: 550, height: 600);

class TodoApp : Component
{
    public override Element Render()
    {
        var initialItems = UseMemo(() => new List<TodoItem>
        {
            new("todo-1", "Learn Reactor basics", true),
            new("todo-2", "Build a todo app", false),
            new("todo-3", "Explore hooks", false),
        });
        var (items, updateItems) = UseReducer(initialItems);
        var (newText, setNewText) = UseState("");
        var (nextId, setNextId) = UseState(4);

        var doneCount = items.Count(i => i.Done);

        return VStack(16,
            Heading("Todo List"),
            TextBlock($"{doneCount}/{items.Count} completed").Opacity(0.6),

            // 输入行
            HStack(8,
                TextBox(newText, setNewText, placeholderText: "What needs to be done?")
                    .AutomationName("New todo")
                    .Width(300),
                Button("Add", () =>
                {
                    if (!string.IsNullOrWhiteSpace(newText))
                    {
                        var text = newText.Trim();
                        updateItems(list => [.. list, new TodoItem($"todo-{nextId}", text, false)]);
                        setNextId(nextId + 1);
                        setNewText("");
                    }
                }).IsEnabled(!(string.IsNullOrWhiteSpace(newText)))
            ),

            // 条目列表
            VStack(4,
                items.Select((item, _) =>
                    HStack(8,
                        CheckBox(item.Done, done =>
                            updateItems(list =>
                            {
                                var copy = new List<TodoItem>(list);
                                var itemIndex = copy.FindIndex(i => i.Id == item.Id);
                                if (itemIndex >= 0)
                                    copy[itemIndex] = item with { Done = done };
                                return copy;
                            }),
                            label: item.Text
                        ),
                        Button("Remove", () =>
                            updateItems(list =>
                            {
                                var copy = new List<TodoItem>(list);
                                copy.RemoveAll(i => i.Id == item.Id);
                                return copy;
                            })
                        ).AutomationName($"Remove {item.Text}")
                    ).WithKey(item.Id)
                ).ToArray()
            ),

            // 清除已完成按钮
            When(doneCount > 0, () =>
                Button($"Clear completed ({doneCount})", () =>
                    updateItems(list => list.Where(i => !i.Done).ToList())
                ).AutomationName("Clear completed todos")
            )
        ).Padding(24);
    }
}

Todo app

值得注意的关键模式:

  • UseReducer 类似于 UseState,但它的 setter 接收的是一个 Func<T, T> 函数——你把上一个值转换成下一个值。当新状态依赖于旧状态时(比如往列表里追加),它就是正确的工具。
  • items.Select(...).ToArray() 把数据映射为元素。Reactor 借助 key 高效地协调列表。
  • WithKey 为每个条目提供稳定的身份标识,这样 Reactor 就能重排、新增和删除条目,而无需重建整个列表。
  • When(condition, () => element) 按条件渲染内容,避免 if/else 把元素树弄得凌乱。

构建一个计算器

下面是一个更复杂的例子,它管理着多块相互关联的状态:

using Microsoft.UI.Reactor;
using Microsoft.UI.Reactor.Core;
using static Microsoft.UI.Reactor.Factories;
using Microsoft.UI.Xaml;

ReactorApp.Run<CalculatorApp>("Calculator", width: 380, height: 500);

class CalculatorApp : Component
{
    public override Element Render()
    {
        var (display, setDisplay) = UseState("0");
        var (operand, setOperand) = UseState<double?>(null);
        var (op, setOp) = UseState<string?>(null);
        var (resetNext, setResetNext) = UseState(false);

        void PressDigit(string digit)
        {
            if (resetNext || display == "0")
            {
                setDisplay(digit);
                setResetNext(false);
            }
            else
            {
                setDisplay(display + digit);
            }
        }

        void PressOp(string nextOp)
        {
            var current = double.Parse(display);
            if (operand.HasValue && op != null)
            {
                var result = Calculate(operand.Value, current, op);
                setDisplay(FormatResult(result));
                setOperand(result);
            }
            else
            {
                setOperand(current);
            }
            setOp(nextOp);
            setResetNext(true);
        }

        void PressEquals()
        {
            if (operand.HasValue && op != null)
            {
                var current = double.Parse(display);
                var result = Calculate(operand.Value, current, op);
                setDisplay(FormatResult(result));
                setOperand(null);
                setOp(null);
                setResetNext(true);
            }
        }

        void PressClear()
        {
            setDisplay("0");
            setOperand(null);
            setOp(null);
            setResetNext(false);
        }

        Element NumButton(string digit) =>
            Button(digit, () => PressDigit(digit))
                .AutomationName($"Digit {digit}")
                .Width(60).Height(48);

        Element OpButton(string label, string opCode) =>
            Button(label, () => PressOp(opCode))
                .AutomationName(opCode switch
                {
                    "+" => "Add",
                    "-" => "Subtract",
                    "*" => "Multiply",
                    "/" => "Divide",
                    _ => $"Operator {label}"
                })
                .Width(60).Height(48);

        return VStack(4,
            // 显示屏
            TextBlock(display)
                .FontSize(32).Bold()
                .HAlign(HorizontalAlignment.Right)
                .Padding(horizontal: 12, vertical: 8),

            // 按钮网格
            HStack(4, Button("C", PressClear).Width(60).Height(48),
                       NumButton("7"), NumButton("8"), NumButton("9")),
            HStack(4, OpButton("/", "/"),
                       NumButton("4"), NumButton("5"), NumButton("6")),
            HStack(4, OpButton("*", "*"),
                       NumButton("1"), NumButton("2"), NumButton("3")),
            HStack(4, OpButton("-", "-"),
                       NumButton("0"), OpButton("+", "+"),
                       Button("=", PressEquals)
                          .AutomationName("Equals")
                          .Width(60).Height(48))
        ).Padding(16);
    }

    static double Calculate(double a, double b, string op) => op switch
    {
        "+" => a + b,
        "-" => a - b,
        "*" => a * b,
        "/" => b != 0 ? a / b : 0,
        _ => b,
    };

    static string FormatResult(double value) =>
        value == Math.Floor(value) ? $"{value:F0}" : $"{value:G10}";
}

Calculator

这个例子展示了普通的 C# 控制流(方法、switch 表达式、局部函数)在 Reactor 组件中是如何自然工作的。这里不需要什么特殊的命令模式——只需调用 setDisplay(...),UI 就会更新。

模式

dotnet watch 实现热重载

最快的编写循环就是在项目目录下执行 dotnet watch run。Reactor 的开发工具会挂接 watch 的文件变更事件,因此在 App.cs 里保存一下,就会重新执行 Render(),而无需重启窗口。保存在 UseState 中的状态会跨这次补丁保留下来(Hook 槽位表得以存活),所以一个停在 42 的计数器,在调整完布局之后仍然是 42。保存在静态字段中的状态则不会被保留——如果你希望启动状态能挺过热重载,就把它放在 UseState 里。

第一个事件、第一份状态——最小可交互应用

每个 Reactor 应用最终都由同样的两种原料构成:一个调用 setter 的事件处理器,以及一个从该 setter 的状态槽中读取并渲染出来的值。上面的 hello-world 片段把 setName 接到 TextBox 的变更处理器上,又在 TextBlock("Hello, ...") 那一行把 name 读回来——这一来一回就是响应式的全部契约。一旦你对此习以为常,其他每一个 Hook 都只是它的特化形式(UseReducer 用于派生态更新,UseEffect 用于副作用,UseRef 用于不参与渲染的记账)。

带开发工具运行

开发菜单需要两个彼此独立的信号,而且都不是 #if DEBUG。首先是构建期能力——Reactor.DevtoolsSupport 功能开关,dotnet new reactorapp 已经在 Debug 配置中连同 Microsoft.UI.Reactor.Devtools 包一起设好了。其次是命令行上的一次会话级启用:

dotnet run -- --devtools app

脚手架生成的 Properties/launchSettings.json 内置了第二个名为 "<AppName> Devtools" 的启动配置文件,它会替你传这个开关,所以在 Visual Studio 中选择该配置文件,或使用 dotnet run --launch-profile "<AppName> Devtools" 即可。默认配置文件有意不传任何参数——直接 dotnet run -c Debug 启动的应用没有开发菜单。

UseDevtools() 只有在两个信号都存在时才返回 true,正是它控制着协调高亮覆盖层以及菜单的其余部分。Release 构建会去掉该包与该开关,因此开发工具的代码会被完全裁掉。开发工具页面介绍了完整的菜单。

常见错误

通过修改 bin/ 产物来"看到改动"

Reactor 并不监听构建输出。请编辑项目下的源文件(App.cs、子目录中的组件)并重新构建——通过 dotnet run 或在 dotnet watch run 下进行。bin/ 目录在每次构建时都会重新生成,在那里做的任何手工修改都会被静默覆盖。

试图在 WinUI 的 PageUserControl 中使用 Reactor

Reactor 期望自己拥有整个窗口。ReactorApp.Run<T> 会打开一个 Window,直接挂载你的组件树,并从该根节点驱动协调器。把 Reactor 组件挂载进 WinUI 的 Page(通过 xmlns:reactor=... 标记)是行不通的——并不存在用于 Reactor 元素的 XAML 加载器。如果你需要把 Reactor 放进已有的 WinUI/WinForms 宿主,参见 WinForms 互操作中的 XamlIslandControl,或在 WinUI 宿主场景下使用组件中的 ReactorHostControl

出于习惯去用 INotifyPropertyChanged

XAML 开发者常试图用视图模型来承载状态。而在 Reactor 中,状态本身就是绑定——UseState 返回 (value, setter),调用 setter 即触发重新渲染。你依然可以用 UseObservable 桥接已有的 INotifyPropertyChanged 数据源(见高级模式),但对于新写的界面,Hook 是更短的路径。面向 XAML 开发者的 Reactor一页把每种 XAML 惯用法都映射到了对应的 Reactor 写法。

小贴士

用函数思考,而不是对象。 你的 Render() 方法是一个从状态到 UI 的纯函数。每次状态变化,它都从头再跑一遍。不要试图以命令式的方式去修改 UI。

把状态放在它需要的最低层级,但不要更高。 如果只有一个组件用到某个值,就在该组件里 UseState。如果兄弟组件需要共享状态,就把它提升到它们的父组件。

用 record 承载数据。 C# 的 record 免费为你提供不可变数据与值相等性。Reactor 正是借此实现高效记忆化的——如果你的 props 在结构上没有变化,该组件就会跳过重新渲染。

优先组合而非继承。 构建各自只做一件事的小组件,再在父组件中把它们组合起来。你几乎不需要 ComponentComponent<TProps> 之外的基类。

善用流畅修饰符。 不要为了简单样式就把元素裹进布局容器,而是链式调用修饰符:TextBlock("hi").Margin(8).Bold() 读起来干净利落,也避免了不必要的嵌套。

下一步