Microsoft.UI.Reactor(Reactor)的开发工作流围绕快速反馈构建。运行时的设计
目标是把它在做的事呈现出来 —— 每一次渲染、
每一次协调、每一个副作用、每一次调度器跳转 —— 通过一小组专门的工具,
让你不必借助性能分析器就能看见、归因并推断
你的应用在做什么。mur CLI、dotnet watch 下的预览
模式、MCP 服务器、VS Code 面板、应用内开发菜单,以及协调高亮
叠加层,都是同一个想法的变体:运行时公布它做的工作,
内循环让你读到它。代价在零售版中为零 ——
每个开发工具入口都位于一个构建期能力开关
(Reactor.DevtoolsSupport)加上一个会话期选择加入(--devtools app)
之后,因此最终用户永远看不到你不打算交付的东西。
开发工具¶
Reactor 的内循环以 dotnet watch 与预览模式为中心:你编辑
代码、保存,然后在正在运行的窗口里看到变化 —— 无需手动重启,大多数时候
也不用重置状态来重载类。围绕这个循环有
五个互补的面 —— mur CLI、
MCP 服务器、VS Code 面板、
应用内开发菜单,以及
运行时叠加层 —— 每一个只在你要求时才亮起。
预览模式¶
从 dotnet watch 启动应用,即可使用 Reactor 对热重载友好的
预览工作流:
// Program entry point — this is the entire App.cs file:
// ReactorApp.Run<DevToolingApp>("Dev Tooling Demo",
// width: 600, height: 450
// );
//
// Hot reload works when the app is launched under dotnet watch. Devtools
// screenshot capture is enabled by the app project's Reactor.DevtoolsSupport
// switch and activated by launching with --devtools.
应用入口就是一次普通的 ReactorApp.Run 调用;并没有
preview: 或 devtools: 参数。开发工具专用的截图采集由应用项目的
Reactor.DevtoolsSupport 开关启用,并在启动时通过 --devtools 激活。
使用热重载运行¶
用 dotnet watch 启动应用:
它会启动应用并监视你的 .cs 文件变化。当你
保存时,dotnet watch 会重新编译,Reactor 会就地重渲染组件树 ——
同一个窗口、同一个进程。对于大多数编辑,你的实时
状态都能挺过这次重载。
下面是预览应用运行时的样子:
class DevToolingApp : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
var (message, setMessage) = UseState("Edit this code and save!");
return VStack(16,
Heading("Preview Mode Demo"),
TextBlock(message).FontSize(16),
HStack(8,
Button("Click me", () => setCount(count + 1)),
TextBlock($"Clicked {count} times").SemiBold()
),
TextBox(message, setMessage, placeholderText: "Type something",
header: "Message")
.Width(300)
).Padding(24);
}
}

改一下 message 的默认值,或新增一个元素,保存文件,
然后看窗口更新。
重载后哪些状态会存活¶
Reactor 会把实时状态迁移过多次热重载,而不是重置它。
当你保存时,运行时会针对既有的
RenderContext 重跑 Render(),并把结果协调到活的
控件上。Hook 单元是按调用顺序匹配的,因此只要 Hook 序列不变,
UseState、UseReducer、UseRef、UseMemo 与 UsePersisted 都会保有自己的值。
当你编辑某个 Hook 所存储的记录或类时,运行时会逐字段把值迁移
到新的形状上;它无法映射的字段会被丢弃(并留下一行日志),
而不是抛异常。当你重命名组件或改变某组件的类型时,
Reactor 会把该子树迁移到新的组件实例上,保住
它的 Hook 状态与底层 WinUI 控件。
| 你做的编辑 | 状态会发生什么 |
|---|---|
修改 Render() 方法体,增/删/重排元素 |
保留 —— 控件就地修补 |
| 编辑 Hook 中存储的值类型或记录 | 保留 —— 逐字段迁移;无法映射的字段被丢弃 |
| 重命名组件类型/改变其身份 | 保留 —— 子树迁移到新实例上 |
改变所存值的字段类型,例如 int → Optional<int> |
不迁移 —— 热重载复制器按名称与兼容类型匹配字段;要跨过这种模式边界请重启或重新挂载 |
| 增删 Hook 调用或改变其顺序 | 重置 —— Hook 列表被清空并重新挂载 |
最后一行是无法回避的情形:改变 Hook 的形状意味着 旧值无法重新键控到新布局上,因此 Reactor 会运行待处理的 清理函数并从零重新挂载 Hook,以保持循环存活, 而不是把你留在错误回退状态。
注意: 依赖在重载前后没有变化的副作用,会保留它从上个实例捕获的清理 闭包。这与 React 的身份语义一致,对捕获状态的副作用无害, 但如果某个副作用必须在每次重载时重跑,请给它一个会变化的依赖。对于你 明确希望连 Hook 形状变化都挺过去的状态,请使用
UsePersisted配PersistedScope.Window,或把值存到Observable<T>字段中 —— 见 持久化。
迁移行为异常时¶
状态迁移是尽力而为的。如果某次不寻常的编辑让组件进入
坏状态,恢复手段是完全重启:停掉 dotnet watch 再重新启动,
这会从零重新挂载每一个 Hook。运行时自己的
「丢掉一切、重新挂载」(HotReloadService.ResetAllContexts)
路径是框架内部的,在检测到 Hook 形状变化时自动运行 —— 它不是
你的应用会调用的 API。只有当上面那些有针对性的迁移没有产生
你期望的结果时,才动用重启。
NativeAOT 构建¶
状态迁移依赖 .NET Hot Reload,而它只在
JIT 调试构建中可用。在 NativeAOT(PublishAot=true)下
MetadataUpdater.IsSupported 为 false,因此整个迁移子系统
在静态上就是死代码并被裁剪掉 —— 发布的成品应用中既没有热重载循环,
也没有相应开销。
函数组件入口¶
做快速试验时,完全可以跳过类。给
ReactorApp.Run 传一个 lambda:
// Alternative: inline function component, no class needed
// ReactorApp.Run("Quick Test", ctx =>
// {
// var (n, setN) = ctx.UseState(0);
// return VStack(12,
// TextBlock($"Count: {n}").FontSize(20),
// Button("+1", () => setN(n + 1))
// ).Padding(24);
// }, width: 400, height: 300);
这对于一次性的原型,或测试单个交互很有用。 你能得到同样的热重载行为 —— 编辑这个 lambda、保存、看结果。 实践范例目录 收集了更多符合这种形态的 例子。
mur CLI¶
mur 是 Reactor 感知仓库的命令行工具。它是文档流水线、本地化工作流、
开发工具服务器,以及若干仓库维护实用程序的
正统入口。各子命令与下面的工作流一一对应。
| 子命令 | 用途 | 常见调用 |
|---|---|---|
mur docs compile |
编译文档集(模板 + 文档应用 → docs/guide/) |
mur docs compile |
mur docs check-tier |
只运行分级检查,不做交叉链接/引用/生成 | mur docs check-tier --topic hooks |
mur docs render-diagrams |
把 .mmd Mermaid 图渲染为 .svg,加快内循环迭代 |
mur docs render-diagrams --topic architecture-overview |
mur docs new-diagram <topic> <id> |
为某个主题脚手架出新的 Mermaid .mmd |
mur docs new-diagram hooks slot-table |
mur loc |
运行本地化流水线(extract、translate、validate、status、prune) |
mur loc extract |
mur devtools |
以 --devtools run 启动项目、监管重载,并托管 MCP 端点 |
mur devtools |
mur check |
仓库健康检查(cref 有效性、命名空间策略、「你是不是想写」建议) | mur check |
mur doctor |
校验安装 —— SDK、mur、本地源、模板、插件 |
mur doctor |
mur upgrade |
git pull 之后重新打包框架 + 模板并刷新插件 |
mur upgrade |
mur figma watch |
轮询 Figma 文件的设计变更 | mur figma watch |
mur pack-local / mur clean-local |
为源码构建的框架冒烟测试打包/清理本地 NuGet 源;除非提供 --MSUIReactorVersion,否则应用模板默认使用公开的 Reactor 预览版 |
mur pack-local |
除子命令之外,还有四个值得了解的顶层选项:
mur --create <Name> 脚手架出新的 Reactor 项目,mur --skill
打印内嵌的 SKILL.md 智能体参考,mur --api 打印
reactor.api.txt 签名索引,mur --regen-api 从
源码检出重新生成该索引。
mur docs compile 是你最常动用的一条工作流。完整的
面以及让内循环变快的 --validate-only、--no-screenshots
(别名 --skip-screenshots)、--skip-diagrams、--no-build、
--skip-reference 和 --tier <stub|solid|comprehensive>
标志,参见 文档流水线贡献者指南。
MCP 服务器¶
mur devtools 以 --devtools run 启动你的项目、跨重载监管它,
并固定一个可供外部智能体或编辑器接入的
Model Context Protocol 端点。该端点
呈现的是正在运行的应用,而不是仓库:窗口列表、可视化树、
截图、Hook 状态、日志环形缓冲区,以及一组由 UIA 支撑的
动词(click、type、focus、toggle、select、scroll、wait),
用来驱动活的 UI。
mur devtools # 启动 + 监管;打印 MCP_ENDPOINT
mur devtools --mcp-port 9000 # 跨重载固定端口
mur devtools --print-config # 输出适用于 Claude Code / VS Code / Copilot 的 MCP 配置
同样的动词也能不经智能体、直接从 CLI 使用 —— 每一个都 通过锁文件发现机制附着到正在运行的会话上:
mur devtools tree --view summary
mur devtools screenshot --out shot.png
mur devtools click "Button[Increment]"
mur devtools session list
没有 mur devtools serve 子命令,也不会由 dotnet watch
自动启动任何东西 —— mur devtools 本身就是启动器,因此当你想要
智能体集成时,请运行它而不是 dotnet watch run。
更深层的协议面见 DevTools 内部机制。
VS Code 面板¶
Reactor 的 VS Code 扩展位于 src/vscode-reactor/,提供一个
侧边面板,它可以:
- 列出正在运行的应用的组件树。
- 高亮你在树中点击的任何元素所对应的源码行。
- 在编辑器旁渲染最新的截图(与文档流水线产出的 那些 PNG 相同)。
- 切换一个 References 叠加层,用来绘制应用的响应式
引用关系图 ——
descriptor.Reference/.ReferenceList、binding.Reference桥接,以及.LabeledBy或.XYFocusDown这类修饰符边 —— 并把引用环与长期为 null (无法解析)的引用标记为诊断项。参见 焦点与输入内部机制。 - 提供一个「compile docs」按钮,它会去调用
mur docs compile。
该扩展与 MCP 端点(上文)通信,因此一个面板会话
不过是一个长期存活的 mur devtools 进程再加一层 UI。除了标准的
C# Dev Kit,Reactor 没有特殊的编辑器要求 ——
该面板是附加式的。想要内嵌真实 WinUI 表面(而不是流式传输截图)的可交互
Visual Studio 替代方案,见
Visual Studio 内嵌预览。
当你针对一个已经在运行的预览进程使用 Reactor: Connect to Preview 时,
请把该进程输出中的 CAPTURE_PORT=... 与 CAPTURE_TOKEN=...
两个值都填进去。采集服务器在任何端点探测(包括 /status)之前
都要求 bearer 令牌。
应用内开发菜单¶
对于活在运行中应用内部的开发面 —— 标题栏里的
「Dev」项、可切换的调试叠加层、一次性命令 ——
Reactor 暴露三个小原语:UseDevtools()、
DevtoolsMenu(...) 与 Observable<T>。两个彼此独立的信号
共同作用:
- 构建期能力 —— 给应用项目加上该功能开关:
<ItemGroup>
<RuntimeHostConfigurationOption Include="Reactor.DevtoolsSupport"
Value="true" Trim="true" />
</ItemGroup>
这是一个能力门 —— 它本身不会显示任何开发 UI。示例通常只在 Debug 构建中启用它,好让 Release/AOT 二进制继续裁剪掉开发工具代码。
- 会话期选择加入 —— 以
--devtools app运行应用:
只有两者同时具备时,UseDevtools() 才返回 true。它是
RenderContext 的扩展方法,因此你从
函数组件的 ctx 调用它 —— 或者从一个你交给
ctx 的辅助方法里调用,前提是该辅助方法本身也以 Use* 命名,因为
REACTOR_HOOKS_005 只允许从
Render() 或 Use* 方法中调用 Hook —— 并用
三元表达式给仅开发用的元素把关:
static class DevGate
{
// UseDevtools() 是 RenderContext 的扩展方法,因此要从
// 函数组件的 ctx(或任何你交给 ctx 的辅助方法)里访问 —— 并不存在
// Component 级别的转发器。只有当 Reactor.DevtoolsSupport 构建开关与
// `--devtools app` 两者同时具备时,它才返回 true。
//
// 该辅助方法命名为 Use*,因为它消耗一个 Hook 槽位:REACTOR_HOOKS_005
// 只允许从 Render() 或 Use* 命名的方法中调用 Hook,因此若把它
// 命名为 `Shell`,任何复制这段代码的项目都会收到警告。
public static Element UseShell(RenderContext ctx)
{
var dev = ctx.UseDevtools();
return VStack(8,
MainContent(),
dev ? DebugOverlay() : Empty()
);
}
private static Element MainContent() => TextBlock("App content");
// 仅在 dev 为 true 时才被*构造*。在零售版中这个三元表达式只需
// 一次 bool 读取加一次分支 —— 没有元素树,也没有子元素被协调。
private static Element DebugOverlay() =>
TextBlock("debug overlay").Opacity(0.6);
}
DebugOverlay() 只在 dev 为 true 时才被构造。在零售版中
这一行只需一次 bool 读取加一次分支 —— 不分配元素树,
不协调子元素。同样的成本模型延续到
DevtoolsMenu,它只在 UseDevtools() 为 true 时
把自己渲染为标题栏项:
static class DevMenu
{
public static Element UseTitleBar(RenderContext ctx)
{
// 在渲染期间订阅 —— 不要放在菜单构建器内部。构建器
// lambda 在浮出打开时才运行,那不是一个渲染轮次,因此在里面
// 调用 Hook 会破坏 Hook 顺序。
//
// 命名为 Use* 的理由与 DevGate.UseShell 相同:它消耗一个
// Hook 槽位,而 REACTOR_HOOKS_005 要求这件事只能从 Render() 或
// Use* 命名的辅助方法中做。
var debugUI = ctx.UseObservable(AppFlags.DebugUI).Value;
return HStack(8,
TextBlock("My App"),
// DevtoolsMenu 只在 UseDevtools() 为 true 时才把自己
// 渲染为标题栏项,因此同样的成本模型得以延续。
DevtoolsMenu(() => new MenuFlyoutItemBase[]
{
ToggleMenuItem("Debug UI", debugUI,
v => AppFlags.DebugUI.Value = v),
MenuSeparator(),
MenuItem("Clear cache", () => CacheService.Clear()),
MenuItem("Slow mode off", () => AppFlags.SlowMode.Value = false),
})
);
}
}
// 代替你的应用实际会缓存的东西。
static class CacheService
{
public static void Clear() { }
}
Observable<T> 是支撑 AppFlags.DebugUI 这类标志的轻量 INPC
单元。把它们声明为 static readonly 字段:
// Observable<T> 是支撑仅开发用标志的轻量 INPC 单元。
// 把它们声明为 static readonly,好让每个组件共享同一个实例。
public static class AppFlags
{
public static readonly Observable<bool> DebugUI = new(false);
public static readonly Observable<bool> SlowMode = new(false);
public static readonly Observable<bool> ForceDark = new(false);
}
任何想要响应变化的组件都在渲染期间通过
ctx.UseObservable(AppFlags.DebugUI).Value 订阅 —— 而不是在
DevtoolsMenu 的构建器 lambda 内部,那个 lambda 在浮出打开时才运行,
不在渲染轮次中。更完整的可观察量绑定故事见
高级模式。
运行时叠加层¶
开发工具开启时,协调高亮叠加层会附着到正在运行的应用上:
- 协调高亮叠加层。 在最近一次渲染中被协调器修补过的任何 元素周围闪出一个矩形。用于捕获意料之外的重渲染(Reactor 的规则 页覆盖了最常见的原因)。
该叠加层在 DevtoolsMenu 的切换组之下渲染。关闭时
零成本。
迭代循环¶
完整的内循环:
- 运行 在终端里执行
dotnet watch run。 - 编辑 在编辑器里修改某个组件(如果你想让截图和组件树 并排显示,请同时打开 VS Code 面板)。
- 保存 文件 ——
dotnet watch检测到变化并重新编译。 - 查看 运行中窗口里更新后的 UI。
无需手动调用任何构建步骤。应用保持运行,窗口 停留在你离开时的位置。
class IterationDemo : Component
{
public override Element Render()
{
var (items, updateItems) = UseReducer(new List<string>());
var (input, setInput) = UseState("");
return VStack(12,
Heading("Iteration Cycle Demo"),
TextBlock("Add items, then edit this code and save to see hot reload."),
HStack(8,
TextBox(input, setInput, placeholderText: "New item",
header: "New item")
.Width(200),
Button("Add", () =>
{
if (!string.IsNullOrWhiteSpace(input))
{
updateItems(list =>
{
var next = new List<string>(list) { input };
return next;
});
setInput("");
}
})
),
ForEach(items, (item, i) => TextBlock($" - {item}").WithKey($"{i}-{item}"))
).Padding(24);
}
}

模式¶
逐功能的调试标志 + Dev 菜单开关¶
在静态类里把一个标志声明为 Observable<bool>,通过 Dev 菜单的
ToggleMenuItem 暴露它,并在任何组件中通过
ctx.UseObservable(...) 读取它。翻转该标志会重渲染每一个
已订阅的组件 —— 无需任何事件接线样板。这个模式可以扩展到
十几个标志,而不需要配置 UI。
用于文档的无头截图采集¶
文档流水线所用的同一套开发工具管道,你自己的应用也能用:
在应用项目中启用 Reactor.DevtoolsSupport,并以 --devtools run
启动应用,好让截图工具链能采集该窗口。工具链契约见
docs/contributing/doc-pipeline.md。
这就是文档集中每一页都能自动获得截图、
而无需作者手动启动应用的方式。
常见错误¶
只启用开关而没有会话门¶
忘记 Reactor.DevtoolsSupport 是能力门、
而 --devtools app 是激活门,是最常见的
错误。启用开关构建出的二进制在用户面前不显示任何开发 UI ——
是会话期标志在起作用。如果你想让开发工具从零售版中被裁剪掉,
请把该开关保持为仅 Debug,而不是为 Release/AOT 启用它。
在内循环工作中用 UseState 承载真实应用状态¶
UseState 在每次热重载时都会重置。如果你的循环依赖一次
30 秒的登录流程才能到达你正在迭代的界面,请把
相关状态存进 UsePersisted(Window 作用域)
或某个 Observable<T> 字段。热重载会接上新的代码,
而不会重置你的状态。
把应用内开发菜单当作唯一的开发工具面¶
mur devtools 的 MCP 端点与 VS Code 面板是与应用内菜单
不同的面。它们从外部观察正在运行的应用;
而应用内菜单从应用自身内部观察运行时。构建期能力(Reactor.DevtoolsSupport)
加上 --devtools app 才会打开应用内菜单,而外部 MCP
端点需要通过 mur devtools 启动,而不是裸的
dotnet watch run —— 不要指望它会自动启动。
提示¶
让 dotnet watch 一直运行。 不要在两次编辑之间停掉再重启它。 它会自动处理重新编译与重连。
使用小组件。 更小的组件重载更快,因为需要重建的 树更少。尽早把片段抽出去。
留意终端。 热重载失败时(通常是语法
错误),dotnet watch 会打印错误。改好再保存 —— 它会重试。
做实验时用函数组件。
ReactorApp.Run("Test", ctx => ...) 是尝试一个想法最快的方式。
没有类的样板代码。
基准测试用 ARM64 构建。 如果你在 ARM64 硬件上,请用
dotnet run -r win-arm64 构建,以取得原生的性能数字。
后续阅读¶
- 快速上手 —— 上一篇:创建你的第一个应用并学习基础知识
- 组件 —— 下一篇:组件类、props、函数组件、组合
- Hook —— 学习组件内部使用的状态管理原语
- 测试 —— 无头渲染器夹具、快照测试、异步测试模式
- DevTools 内部机制 —— 开发菜单、叠加层与 MCP 服务器是如何实现的