Skip to content

在 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);
    }
}

Basic component

每次状态变化时 Render() 都会被调用。你在顶部调用 UseState 之类的 Hook,然后返回一棵元素树。Reactor 会把结果与上一次渲染做差异比对,只给发生变化的控件打补丁。

用 Record 表达 Props

当组件需要来自父组件的输入时,为它的 props 定义一个 C# record,并继承 Component<TProps>

record AlertProps(string Title, string Message, string Severity = "info");
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);
    }
}

Props component

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 内置的那些元素工厂(ButtonTextBlockFlexRow)读起来都是裸函数;用户自定义的辅助方法应当遵循同样的语法。

自定义 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 的 ComponentShouldUpdate() 默认返回 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 返回 trueGetHashCode 返回 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);
    }
}

Function component

  • Memo(ctx => { ... }) — 一个拥有自身 Hook 状态的内联组件。不带 deps 参数时,它只渲染一次,并且只在自身状态变化时重新渲染。ctx 参数是一个 RenderContext,提供 UseStateUseEffect 以及所有其他 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)
        );
    }
}

Composed components

每个组件都独立管理自己的状态。父组件用 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() 的正确工作。

优先组合,而不是深层继承。 ComponentComponent<TProps> 就是你所需要的所有基类。通过嵌套而非类层次结构来构建复杂性。

对一次性组件使用 Memo 如果一个组件只在一处使用、状态又很简单,那么内联的 Memo(ctx => ...) 既省掉了类的样板代码,又依然能对其渲染做记忆化。

保持 Render() 纯粹。 不要在 Render() 内部修改外部状态或执行 I/O。副作用请用 UseEffectRender() 应当是一个从(状态 + props)到元素的纯函数。

按"显示什么"而不是"做什么"来命名组件。AlertUserCardSettingsPanel——而不是 AlertHandlerUserManagerSettingsProcessor

下一步