在 Microsoft.UI.Reactor(以下简称 Reactor)中,组件是一个从(状态 + props)到元素树的纯函数。每当组件自身状态变化,或父组件渲染产生了新的 props 时,Render() 就会被调用,而它唯一的职责是返回 UI 此刻应该是什么样子。Reactor 拿到这个返回值,通过协调器与上一次渲染做差异比对,然后就地对底层 WinUI 控件打补丁——只有真正发生变化的属性与子节点才会被触碰。"函数"这个定位是承重的:组件不得修改 props,不得在渲染期间写入外部状态,并且在每次渲染中都必须以相同的位置顺序调用 Hook。副作用归 UseEffect;派生的值归 UseMemo 或内联表达式;订阅归带清理逻辑的副作用。把这几条规则做对,组件就成为整个框架赖以构建的组合单元——类组件用于有状态的节点,函数组件通过 Memo 用于内联形态,两者都通过在父组件 Render() 的返回值中被实例化而组合成树。
组件¶
组件是 Reactor 应用的构建单元。每个组件都是一个带有 Render() 方法的类,该方法返回一棵描述其 UI 的元素树。
速查¶
| 形态 | 适用场景 | 何时重新渲染 |
|---|---|---|
class C : Component |
不需要父组件输入的有状态节点。 | 内部状态变化时(无 props 组件的默认 ShouldUpdate() 返回 false——父组件重新渲染不会自行向下传播)。 |
class C : Component<TProps> |
接收一个 record 作为输入的有状态节点。 |
props 比较不相等时(record 使用结构相等性),或内部状态变化时。 |
Memo(ctx => …) |
拥有自身 Hook 状态的内联函数组件。 | 内部状态变化时;若带 deps 参数,则任一依赖项变化时也会重新渲染。 |
RenderEachTime(ctx => …) |
应当始终随父组件一起重新渲染的内联组件。 | 每次父组件渲染时——即退出记忆化。 |
Component<C, TProps>(new(…)) |
在另一个组件的渲染中实例化一个强类型 props 的类组件。 | 由上述规则决定。 |
重写 ShouldUpdate(TProps? old, TProps? new),可以让一个 Component<TProps> 忽略某些 prop 的变化(例如纯装饰性的标签更新)。基于 record 的默认 Equals() 检查在大多数情况下就是正确的选择。自动生成的组件参考会覆盖每一个成员(即将推出——现有原型见 Hooks 参考);本页余下部分是叙述性内容。
基础组件¶
继承 Component 并重写 Render():
class Greeting : Component
{
public override Element Render()
{
var (name, setName) = UseState("World");
return VStack(12,
TextBlock($"Hello, {name}!").FontSize(20).Bold(),
TextBox(name, setName, placeholderText: "Your name")
.AutomationName("Name")
.Width(200)
).Padding(16);
}
}

每次状态变化时 Render() 都会被调用。你在顶部调用 UseState 之类的 Hook,然后返回一棵元素树。Reactor 会把结果与上一次渲染做差异比对,只给发生变化的控件打补丁。
用 Record 表达 Props¶
当组件需要来自父组件的输入时,为它的 props 定义一个 C# record,并继承 Component<TProps>:
class Alert : Component<AlertProps>
{
public override Element Render()
{
var bg = Props.Severity switch
{
"error" => Theme.SystemCriticalBackground,
"warning" => Theme.SystemCautionBackground,
_ => Theme.SystemSuccessBackground
};
return Border(
VStack(4,
TextBlock(Props.Title).Bold(),
TextBlock(Props.Message)
).Padding(12)
).Background(bg).CornerRadius(4);
}
}

Record 为你提供不可变数据与值相等性。Reactor 正是借此实现自动记忆化——如果父组件重新渲染了,但 props 在结构上没有变化,子组件就会跳过它的 Render() 调用。
在 Render() 内部通过 Props.PropertyName 访问 props。父组件在创建组件实例时,通过给 Props 属性赋值来设置 props。
用工厂辅助方法让调用点更干净¶
Component<T, TProps>(new(...)) 在调用点读起来很重,尤其是嵌套在元素树中时。Reactor 的惯用做法是把每个类组件包进一个自由函数工厂,使其与 DSL 的其余部分风格一致:
static class Components
{
public static ComponentElement Alert(string title, string message,
string severity = "info") =>
Component<global::Alert, AlertProps>(new(title, message, severity));
}
在使用方文件顶部写上 using static Components;,调用点就坍缩成一次普通的函数调用:
| 之前 | 之后 |
|---|---|
Component<Alert, AlertProps>(new("Saved", "Done")) |
Alert("Saved", "Done") |
Component<Alert, AlertProps>(new("Hi", "x", "warn")) |
Alert("Hi", "x", "warn") |
类 Alert 与辅助方法 Alert 可以在同一作用域中共存——C# 会依据语法位置,把 Alert(args) 解析为方法调用,把 Component<Alert, ...> 解析为类型引用。
约定:
- 与组件同名。 类
Alert配辅助方法Alert,读起来就像 JSX:用Alert(...)代替<Alert ... />。 - 把辅助方法集中在一个静态类里,命名为
Components(或按功能划分)。每个使用方文件只需一条using static导入就足够了。 - 不要加
Create/Of/New前缀。 Reactor 内置的那些元素工厂(Button、TextBlock、FlexRow)读起来都是裸函数;用户自定义的辅助方法应当遵循同样的语法。
自定义 ShouldUpdate¶
重写 ShouldUpdate 以控制组件何时重新渲染:
record ExpensiveProps(string Label, int Value);
class ExpensiveDisplay : Component<ExpensiveProps>
{
protected override bool ShouldUpdate(
ExpensiveProps? oldProps, ExpensiveProps? newProps)
{
// 只在 Value 变化时重新渲染,忽略 Label
return oldProps?.Value != newProps?.Value;
}
public override Element Render()
{
return TextBlock($"Value: {Props.Value}").FontSize(18).Bold();
}
}
Component<TProps> 的默认行为使用 Equals()——配合 record 就意味着结构相等性。当你想要更粗粒度的控制时(比如忽略装饰性的 prop 变化),就重写 ShouldUpdate。
对于无 props 的 Component,ShouldUpdate() 默认返回 false,意味着该组件只因自身状态变化而重新渲染。重写并返回 true,则每当父组件重新渲染时它也跟着重新渲染。
回调 props 与记忆化¶
记忆化通过 Equals() 比较 props。对 record 而言就是逐字段比较,而委托字段(Action/Func)按引用比较。父组件几乎总会在每次渲染时传入一个新分配的回调(一个 lambda 或局部函数),因此一个 props 中带有内联 Action 字段的子组件,在每一次渲染中都会比较不相等,从而即使没有任何可观测数据发生变化也会重新渲染:
record StepModel(int Id, string Name);
// 每次父组件渲染都会重新渲染——OnChanged 每次都是一个新的委托。
record StepPropsNaive(StepModel Step, Action<string> OnChanged);
把这些回调包进 Callbacks<T>,它们的身份就会从记忆化比较中被排除。Callbacks<T> 是一个恒相等的 record(Equals 返回 true,GetHashCode 返回 0),因此所属 record 自动生成的相等性会把回调槽视为恒相等——只有数据字段才会驱动重新渲染的决策:
record StepCallbacks(Action<string> OnChanged, Action OnRun);
// 现在只有 Step 驱动重新渲染;回调槽被记忆化忽略。
record StepProps(StepModel Step, Callbacks<StepCallbacks> Cb);
class StepRow : Component<StepProps>
{
public override Element Render() =>
HStack(8,
TextBlock(Props.Step.Name),
// 在事件发生时才从 Props 上读取回调——
// 绝不要在渲染期把它捕获进局部变量。
Button("Run", () => Props.Cb.Value.OnRun()));
}
static class StepPropsFactory
{
public static StepProps Create(StepModel step, Action<string> onChanged, Action onRun) =>
// 构造它——负载会隐式转换:
new StepProps(step, new StepCallbacks(onChanged, onRun));
}
这取代了过去"在每个 props record 上手写 Equals/GetHashCode(只列出数据字段)"的变通办法——那种做法每个 record 要写约 10 行样板代码,而且一旦漏掉某个字段,就会埋下一个静默的 UI 陈旧 bug。
在派发时刻实时读取回调。 当协调器因为数据未变而跳过某个子组件的重新渲染时,它仍然会用最新值刷新该子组件的 Props,因此在事件发生时读取 Props.Cb.Value.OnChanged 的处理器,调用的永远是当前委托——而绝不会是被记忆化后过期的那个。请在事件触发时从 Props 上读取回调;不要渲染期就把 Props.Cb.Value.OnChanged 捕获进局部变量并一直持有。
函数组件¶
并非一切都需要一个类。对拥有自身 Hook 状态的轻量内联组件,可以使用 Memo:
class FunctionComponentDemo : Component
{
public override Element Render()
{
return VStack(12,
SubHeading("Function components"),
// Memo:渲染一次 + 自身状态变化时重新渲染(最常见的情况)。
Memo(ctx =>
{
var (on, setOn) = ctx.UseState(false);
return HStack(8,
ToggleSwitch(on, setOn),
TextBlock(on ? "Active" : "Inactive")
);
}),
// 带依赖项的 Memo:依赖项未变时跳过重新渲染。
Memo(ctx =>
{
return TextBlock("I only re-render when deps change")
.Opacity(0.6);
}, "stable-dep")
).Padding(16);
}
}

Memo(ctx => { ... })— 一个拥有自身 Hook 状态的内联组件。不带deps参数时,它只渲染一次,并且只在自身状态变化时重新渲染。ctx参数是一个RenderContext,提供UseState、UseEffect以及所有其他 Hook。Memo(ctx => { ... }, deps)— 同上,但当deps中的任一值变化时也会重新渲染。把它用于依赖外部 props 的高开销子树(参见 Hooks 中的UseMemo)。RenderEachTime(ctx => { ... })— 让该组件重新回到"每次父组件渲染都跟着重渲染"的行为。请谨慎使用——它会破坏记忆化,并可能放大渲染风暴。只在你确定自己需要这种始终重渲染的行为时才选它。
旧版的
Func(ctx => ...)工厂已在本版本中移除。常见情况请改用Memo(ctx => ...);当你明确想要"始终重新渲染"的形态时,用RenderEachTime(ctx => ...)。
你也可以把函数组件用作应用的根节点:
ReactorApp.Run("Title", ctx =>
{
var (n, setN) = ctx.UseState(0);
return Button($"Count: {n}", () => setN(n + 1))
.AutomationName("Increment count");
}, width: 400, height: 300);
组合¶
通过嵌套组件来构建复杂的 UI:
class ComponentsApp : Component
{
public override Element Render()
{
var (count, setCount) = UseState(0);
return ScrollView(
VStack(16,
Heading("Component Patterns"),
Component<Greeting>(),
Component<Alert, AlertProps>(new("Success", "It works!")),
Component<Alert, AlertProps>(new("Oops", "Something broke",
"error")),
HStack(8,
Button("+1", () => setCount(count + 1)),
Component<ExpensiveDisplay, ExpensiveProps>(
new("Counter", count))
),
Component<FunctionComponentDemo>()
).Padding(24)
);
}
}

每个组件都独立管理自己的状态。父组件用 new 创建子组件并设置它们的 Props。剩下的——挂载、更新、随树变化而卸载——都由 Reactor 处理。
注意: 跨渲染的身份稳定性,正是一个组件得以在渲染之间保住自己的状态、Hook 槽位与 WinUI 控件实例的原因。协调器默认按位置匹配子节点——第 N 次渲染中的第一个子节点会匹配第 N+1 次渲染中的第一个子节点,无论它是什么类型。当列表只做追加、顺序稳定时这没问题;但一旦条目发生重排,后果就是灾难性的,因为第二个位置上的状态格现在已经属于另一个条目了。请在
ForEach/ 集合渲染的每一个子节点上加上.WithKey(item.Id)(或任意稳定的键),让协调器改为按身份匹配。协调(Reconciliation) 一章完整讲解了那个四阶段的键控算法。经典的故障形态是:一个待办清单里删除第 2 项之后,第 3 项的复选框状态出现在了第 2 项上——因为从位置上看,槽位 2 依然被占着,协调器更新的是那个已存在的组件,而不是挂载一个新的。修复方式就是每行加一次.WithKey(item.Id)。
模式¶
带子内容的组合¶
组件嵌套是 Reactor 组合 UI 的主要方式——这里没有"插槽"或 ContentPresenter 的概念;父组件的 Render() 直接返回子元素树:
record CardProps(string Title, Element Body);
class Card : Component<CardProps>
{
public override Element Render() =>
Border(
VStack(8,
TextBlock(Props.Title).Bold(),
Props.Body // 任意 Element,包括子组件
).Padding(12)
).CornerRadius(8).WithBorder(Theme.CardStroke);
}
由调用方决定往里面放什么——一个 TextBlock、一个 Component<Form>,甚至是一棵由 ForEach 在运行时构建的树。这相当于 React 的 children prop 或 XAML 的 ContentControl,但用普通 C# 表达,且不需要任何绑定样板代码。
与 render props 等价的写法¶
当一个组件需要把部分渲染工作委托给调用方时,就接一个 Func<…, Element> 类型的 prop。这个函数会在组件的 Render() 内部被调用,其返回值被拼接进来——与 React 的 render props 或 DataTemplate 选择器模式相同:
record ItemsListProps<T>(
IReadOnlyList<T> Items,
Func<T, Element> Render,
Func<T, string> Key);
class ItemsList<T> : Component<ItemsListProps<T>>
{
public override Element Render() =>
VStack(4, ForEach(Props.Items,
item => Props.Render(item).WithKey(Props.Key(item))));
}
调用方决定每一行的形态——item => TextBlock(item.Name),或是一个完整的嵌套组件——并提供一个键选择器,这样列表组件除了"如何渲染"和"如何标识"之外,对条目类型一无所知。(.WithKey 既接受 string 键,也接受实现了
IReactorKeyed 的条目;无约束的泛型 T 则需要那个选择器。)
状态提升¶
当两个兄弟组件需要协同工作时,把状态上提到它们共同的父组件,再把 (value, setter) 传下去。这正是
recipes/master-detail 所用的模式:主列表与详情面板都响应同一个由父组件持有的 selectedId。两个兄弟组件保持纯粹——接收 props 进来,回调出去。该模式所组合出的 UseState 形态见 Hooks。
与 ErrorBoundary 集成¶
用 ErrorBoundary 包住一棵子树,以捕获其任意子组件的 Render 调用抛出的异常。边界会换上你的回退元素,而不是让异常冒泡到宿主——后者会卸载整棵树:
class RiskyView : Component
{
public override Element Render() => TextBlock("Risky content");
}
class ErrorBoundaryComposition : Component
{
public override Element Render() =>
ErrorBoundary(
fallback: ex => TextBlock($"Crash: {ex.Message}")
.Foreground(Theme.SystemCritical),
child: Component<RiskyView>()
);
}
高级模式一页会介绍其生命周期与恢复契约;在组件这一层,要点在于边界本身也是组件,可以按你想要的隔离粒度组合进树里。
常见错误¶
在渲染中做副作用¶
static class Globals { public static int RenderCount; }
class SideEffectsInRenderDont : Component
{
public override Element Render()
{
// 不要这样——每次用户操作都可能触发任意次数的 Render()。
File.AppendAllText("log.txt", "rendered\n"); // 渲染期做 I/O
Globals.RenderCount++; // 渲染期做修改
return TextBlock("hi");
}
}
每次用户操作都可能触发任意次数的 Render()——每个状态变化一次,还要加上开发工具可能触发的诊断性渲染。副作用必须移进 UseEffect,这样它们才会每次提交只运行一次。诊断计数器应放在 UseRef 里,它可以在不调度重新渲染的前提下做修改。
修改 props¶
record ItemsProps(List<string> Items);
class MutatingPropsDont : Component<ItemsProps>
{
public override Element Render()
{
// 不要这样——这会修改父组件的集合,
// 于是父组件永远看不到这次变化,也就不会重新渲染。
Props.Items.Add("new item");
return VStack(4, ForEach(Props.Items, (item, i) => TextBlock(item).WithKey($"{i}-{item}")));
}
}
Props 是来自父组件的输入——按契约是只读的。修改底层集合会绕过父组件的状态 setter,于是父组件永远看不到这次变化,也就不会重新渲染。更糟的是,下一次父组件真的重新渲染并再次把 Items 传下来时,你这次局部修改已经静默地改变了父组件的快照。修复方式是通过 prop 的 setter 回调:接收一个 Action<Item> OnAdd 类型的 prop,让父组件通过 UseState 持有这个列表。
在渲染中创建组件¶
// 不要这样:
public override Element Render()
{
class LocalComponent : Component { ... } // 甚至不是合法的 C#,但
var inline = (Component)CreateLocal(); // 任何"每次渲染造一个类"的形态都同理
return Component<inline>();
}
协调器按类型身份进行匹配——每次渲染都新建一个组件类,会与上一次渲染的类比较不相等,于是子组件在每次提交时都会卸载再重新挂载,丢掉全部 Hook 状态与 WinUI 控件实例。请在文件的顶层定义组件类(或者对内联形态使用 Memo(ctx => …),协调器会把它当作一个稳定的函数身份)。同样的反面模式在 React 中会出于同样的原因、同样的根因而丢失状态。
小贴士¶
用 record 表达 props。 它们免费为你提供不可变数据、值相等性以及 with 表达式。Reactor 的记忆化依赖于 Equals() 的正确工作。
优先组合,而不是深层继承。 Component 与
Component<TProps> 就是你所需要的所有基类。通过嵌套而非类层次结构来构建复杂性。
对一次性组件使用 Memo。 如果一个组件只在一处使用、状态又很简单,那么内联的 Memo(ctx => ...) 既省掉了类的样板代码,又依然能对其渲染做记忆化。
保持 Render() 纯粹。 不要在 Render() 内部修改外部状态或执行 I/O。副作用请用 UseEffect。Render() 应当是一个从(状态 + props)到元素的纯函数。
按"显示什么"而不是"做什么"来命名组件。 用 Alert、
UserCard、SettingsPanel——而不是 AlertHandler、UserManager、
SettingsProcessor。
下一步¶
- Hook — 下一篇:深入 UseState、UseReducer、UseEffect 以及全部 Hook
- 开发工具 — 上一篇:用热重载与预览模式加速迭代
- 布局 — 用 VStack、HStack、Grid 与响应式模式排布组件
- 样式与主题 — 为组件应用颜色、字体排版与主题
- 协调(Reconciliation) — 差异比对如何在渲染之间匹配组件,以及为什么键很重要
- 高级模式 — ErrorBoundary、Memo、.Set、自定义 Hook