Skip to content

"高级"这一层,是 Microsoft.UI.Reactor(以下简称 Reactor)的声明式天花板与底下那个命令式 WinUI 运行时相接的地方。框架的大部分设计都在把声明式路径塑造成正确的路径——渲染是状态的函数,协调器把下一条元素记录变成一棵控件树,你自己从来不去 new 一个 Button。但每个真实的应用总有几件装不进去的活:登录报错之后把焦点移到密码框、集成一个已经自带 INPC 故事的既有 MVVM 视图模型、从第三方组件渲染内部的崩溃中恢复、手工调优一个在滚动时会分配的 4,900 格行情表。本页上的这些工具——ErrorBoundaryMemoUseElementRef.Set(...)UseObservableTree、自定义 Hook、直接的记录初始化器单元格构造——就是那些逃生舱。当声明式的形态确实不适合这项工作时就去用它们;而一旦逃生舱的使命完成,就立刻回到声明式的形态。

高级模式

逃生舱速查

工具 什么情况下它是对的锤子 什么情况下不是
ErrorBoundary 包裹一个可能在渲染期间抛异常的第三方子树(插件、市场组件)。 应用级错误处理——那属于宿主代码,不属于渲染树。
Memo(ctx => ..., deps) deps 中没有一个按引用变化时,跳过一棵昂贵子树的重新渲染。 廉价子树——Memo 的记账成本比渲染本身还高。
UseElementRef<T>() 限定在单个元素上的命令式 WinUI 调用:聚焦、滚动、动画句柄。 从控件里读状态——你的组件状态才是真相来源。
.Set(control => ...) Reactor 未暴露的某个 WinUI 属性。 Reactor 已暴露的属性——.Set 会绕过协调的属性差异比对。
UseObservableTree 桥接一个带有嵌套对象的既有 INPC 视图模型。 新代码——请优先用 hooks 中的 UseStateUseReducer
UseCollection 包裹一个你不拥有的 ObservableCollection<T> 自己拥有的列表——用 UseState<ImmutableList<T>>ImmutableArray<T>
自定义 Hook(this RenderContext 在多个组件之间复用一组 Hook 组合。 单个 Render 内部的一次性组合。
Win2D 画布Reactor.Advanced 即时模式绘图、游戏循环,以及保留式 WinUI 控件无法廉价表达的虚拟分块表面。 普通的保留式 UI——请继续用元素、修饰符与动画。
直接的记录初始化器单元格 经过性能剖析的热循环(行情表、压力网格)。 普通界面——保持流畅写法。

错误边界

ErrorBoundary 包裹一棵子树,并捕获渲染期间的异常。它不会让整个应用崩溃,而是显示一个回退元素:

class ErrorBoundaryDemo : Component
{
    public override Element Render()
    {
        return VStack(12,
            SubHeading("Error Boundary"),
            ErrorBoundary(
                Component<BuggyComponent>(),
                (Exception ex) => VStack(8,
                    TextBlock("Something went wrong").Bold()
                        .Foreground(Theme.SystemCritical),
                    TextBlock(ex.Message).FontSize(12).Opacity(0.7)
                ).Padding(12)
                 .Background(Theme.SystemCriticalBackground)
                 .CornerRadius(8)
            )
        ).Padding(24);
    }
}

class BuggyComponent : Component
{
    public override Element Render()
    {
        var (crash, setCrash) = UseState(false);
        if (crash) throw new InvalidOperationException("Oops!");
        return Button("Click to crash", () => setCrash(true));
    }
}

Error boundary with fallback

第一个参数是子子树。第二个参数是一个回退——可以是一个静态元素,也可以是一个接收被捕获 Exception 的函数。当该边界重新渲染时(例如它的父组件更新了),它会重试该子组件。这就给了用户一个机会:通过改变导致崩溃的那个状态来恢复。

重试一棵失败的子树

重试模式使用 WithKey 在每次重试时给子组件分配一个全新的身份——与 React 的 resetKeys 同形。递增这个键会丢掉上一棵子树已挂载的状态,因此一次留下了坏状态的瞬时失败不会持续存在:

class ErrorBoundaryRetryDemo : Component
{
    public override Element Render()
    {
        var (resetKey, setResetKey) = UseState(0);

        return VStack(12,
            SubHeading("ErrorBoundary with retry"),
            ErrorBoundary(
                Component<FlakyComponent>().WithKey($"flaky-{resetKey}"),
                ex => VStack(8,
                    TextBlock("Couldn't load.").Bold().Foreground(Theme.SystemCritical),
                    TextBlock(ex.Message).FontSize(12).Opacity(0.7),
                    // Bumping resetKey reassigns identity to the child, so the
                    // ErrorBoundary mounts a fresh subtree on the next render.
                    Button("Retry", () => setResetKey(resetKey + 1))
                ).Padding(12).Background(Theme.SystemCriticalBackground).CornerRadius(8)
            )
        ).Padding(24);
    }
}

class FlakyComponent : Component
{
    public override Element Render()
    {
        var (attempt, _) = UseState(Random.Shared.Next(0, 3));
        if (attempt == 0) throw new InvalidOperationException("Service unavailable");
        return TextBlock("Loaded.").Foreground(Theme.SystemSuccess);
    }
}

Retryable error boundary

"重试"按钮递增 resetKey,子组件拿到一个新键,协调器就会在下一次渲染时挂载一棵全新的子树。这与导航缓存失效和集合中列表项身份恢复所用的,是同一个身份重置技巧。

Memo

Memo(ctx => ..., deps) 会在其依赖项按引用均未变化时,跳过整棵子树的重新渲染。父组件可以自由地重新渲染——被记忆化的子树保持冻结:

class MemoSubtreeDemo : Component
{
    public override Element Render()
    {
        var (count, setCount) = UseState(0);
        var (label, setLabel) = UseState("Expensive");

        return VStack(12,
            SubHeading("Memo"),
            TextBlock($"Parent renders: click count = {count}"),
            Button("Increment", () => setCount(count + 1)),
            Memo(ctx =>
            {
                // This subtree only re-renders when label changes
                return Border(
                    VStack(4,
                        TextBlock($"Memoized: {label}").Bold(),
                        TextBlock("Skips re-render when deps unchanged")
                            .FontSize(12).Opacity(0.6)
                    ).Padding(12)
                ).Background(Theme.CardBackground).CornerRadius(8);
            }, label)
        ).Padding(24);
    }
}

Memo skipping re-render

Memo 接收一个渲染函数和一组依赖项。只有当至少一个依赖项按引用相等性发生变化时,该子树才会重新渲染。点击"Increment"会重新渲染父组件,但被记忆化的那个 Border 不受影响,因为 label 并没有变。

把 Memo 用于昂贵的子树——大型集合、复杂图表,或不依赖频繁变化状态的深层嵌套组件树。长列表中按单元格的记忆化,参见记忆化列表单元格

命令式引用 — UseElementRef

UseElementRef<T>() 返回一个稳定的 ElementRef<T>,其 .Current 由协调器与已挂载的控件保持同步。用 .Ref(ref) 把这个引用挂到一个元素上,然后从事件处理器或 UseEffect 里调用 .Current,去执行那些 Reactor 没有以声明式方式包装的命令式操作:

class ElementRefFocusDemo : Component
{
    public override Element Render()
    {
        var (name, setName) = UseState("");
        var fieldRef = this.UseElementRef<TextBox>();

        return VStack(12,
            SubHeading("Imperative focus via ElementRef<T>"),
            TextBox(name, setName, placeholderText: "Name")
                .AutomationName("Name")
                .Ref(fieldRef),
            Button("Focus the field", () =>
                fieldRef.Current?.Focus(FocusState.Programmatic))
        ).Padding(24);
    }
}

这个引用实例在重新渲染之间是身份稳定的(该 Hook 每次都返回同一个实例),因此可以安全地放进依赖数组、并被 UseEffect 捕获。.Current 在元素挂载之前为 null;解引用前请做空检查。

注意: 跨渲染持有原始的 FrameworkElement(底下那个 WinUI 控件,而不是 Reactor 的 ElementRef<T>)是不安全的。协调器可能在渲染之间卸载、池化或替换底层控件——你缓存在 static 字段里或某个被捕获的局部变量里的那个控件,此时已经不再挂在树上了,你在它上面设置的任何属性都不会生效。UseElementRef<T> 才是受支持的形态:引用实例是稳定的,但 .Current每次都重新读取的,经由一个由协调器在挂载与卸载时更新的属性。分析器 REACTOR_HOOKS_005 能抓到常见形式(把控件缓存进一个字段、未做空检查就解引用),但通过辅助方法做的间接缓存它看不见——请把这条规则记在脑子里:永远不要保存控件,永远通过引用去访问。

.Set() 逃生舱

.Set() 让你直接触达底层 WinUI 控件。把它用于 Reactor 没有以修饰符形式暴露的那些属性——而且只用于这些。请先确认有没有流畅修饰符:工具提示是 .ToolTip("…") (富内容则用 .WithToolTip(element),另有 .ToolTipPlacement(...) / .ToolTipPlacementTarget(...) 对应 ToolTipService 的两个定位属性),而不是在 .Set() 里手搓一个 ToolTipService.SetToolTip。内边距、文本换行、字符间距与文本选中也都有修饰符。凡是你经由 .Set() 设置的东西,都处在属性差异比对之外,因此只要存在修饰符,就应当优先用修饰符:

class SetEscapeHatchDemo : Component
{
    public override Element Render()
    {
        return VStack(12,
            SubHeading(".Set() Escape Hatch"),
            // Tooltip and padding are first-class modifiers — reach for those
            // first. ClickMode has no modifier, so it needs the escape hatch.
            Button("Custom Tooltip", () => { })
                .ToolTip("This is a native tooltip")
                .Padding(20, 10, 20, 10)
                .Set(btn => btn.ClickMode = ClickMode.Press),
            TextBlock("Styled via .Set()")
                .TextWrapping(TextWrapping.WrapWholeWords)
                .CharacterSpacing(80)
                .IsTextSelectionEnabled()
                .Set(tb =>
                {
                    // No TextBlockElement modifiers for these two.
                    tb.LineStackingStrategy = LineStackingStrategy.BlockLineHeight;
                    tb.IsTextScaleFactorEnabled = false;
                })
        ).Padding(24);
    }
}

.Set() escape hatch

每种元素类型都有一个强类型的 .Set() 重载。Button 接收一个 Action<Microsoft.UI.Xaml.Controls.Button>Text 接收 Action<TextBlock>,依此类推。该回调在挂载与更新时都会运行,因此请以幂等方式设置属性——不要累积事件处理器。当你需要一次性的挂载动作时,请改用 .OnMount(...)

.Set 绕过了协调中的属性差异比对路径——协调器并不知道你通过该回调写入的状态,因此下一次触碰某个真实修饰符的渲染不会把它撤销。请把 .Set 当作单向的:你在驱动控件,而不是让 Reactor 来管理它。

Optional<T> 与归属权

Optional<T> 是 Reactor 用于受控属性的归属权标记。一个受控的元素属性(例如 SliderElement.Value)并不只是一个 double;它要么是 Optional<double>.Of(value)(Reactor 在断言这个值),要么是 Optional<double>.Unset(由 WinUI 控件拥有这个值)。之所以要有这个区分,是因为 C# 的 record 无法像 JSX 那样区分"属性被省略"与"属性被设为默认值"。

Of泛型结构体上的静态成员,因此类型参数要写在 Optional<T> 上,而不是写在 Of 上——是 Optional<double>.Of(5.0),不是 Optional.Of(5.0),也不是 Optional.Of<double>(5.0)

回弹(snap-back)配方

有时应用确实希望把一个可由用户编辑的控件钳制回一个固定值。让元素值保持 HasValue,并从变更回调中强制一次重新渲染:

class SnapBackDemo : Component
{
    public override Element Render()
    {
        // RenderContext.UseReducer<T>(T initialValue) returns
        // (T Value, Action<Func<T, T>> Update). Toggling the bool guarantees
        // a changed reducer result and therefore a re-render.
        var (_, bump) = UseReducer(false);

        return Slider(
            value: Optional<double>.Of(5.0),
            onValueChanged: _ => bump(b => !b));
    }
}

流程是:用户把滑块从 5 拖开,回调翻转 reducer 状态,下一次渲染产出 Optional<double>.Of(5.0),受控的 Update 闸门看到 HasValue 加上偏移,于是带回声抑制地把 5 写回去。只在回弹是刻意行为时才用这个;普通输入应当绑定到状态(Slider(value, setValue)),或在希望由控件拥有该值时使用 Optional<T>.Unset

用于单向回退的 ClearValue 通道

对于由 DP 支撑的单向属性,Optional<T> 还能表达"释放本地值":

public sealed record CardElement : Element
{
    public Optional<Brush?> Background { get; init; } = Optional<Brush?>.Unset;
}

static class CardDescriptorHost
{
    public static readonly ControlDescriptor<CardElement, Microsoft.UI.Xaml.Controls.Border> Descriptor =
        new ControlDescriptor<CardElement, Microsoft.UI.Xaml.Controls.Border>()
            .OneWay(
                get: static e => e.Background,
                set: static (c, v) => c.Background = v,
                dp: Microsoft.UI.Xaml.Controls.Border.BackgroundProperty);
}

Background = Optional<Brush?>.Of(brush) 写入一个本地画刷; Background = Optional<Brush?>.Unset 则调用 ClearValue,让 WinUI 的样式、模板、继承值或注册默认值得以透出。对 null 请写清楚:with { Background = null } 走的是隐式转换,含义是 Optional<Brush?>.Of(null),而不是 Unset

自定义 Hook

自定义 Hook 把既有 Hook 组合成可复用的形态。它们是 RenderContext 上的扩展方法——与内置 Hook 同形,可以从任意函数组件的 Memo(ctx => ...) 中调用,也可以从类组件的 Context 上调用:

// Custom hook — composes UseState + UseEffect on RenderContext. Treated like
// a built-in hook from any function-component Render. Same hook-rules apply:
// call unconditionally, at the top of render, in the same order every time.
static class TogglerHook
{
    public static (bool IsOn, Action Toggle) UseToggler(this RenderContext ctx, bool initial = false)
    {
        var (on, setOn) = ctx.UseState(initial);
        return (on, () => setOn(!on));
    }
}

class CustomHookDemo : Component
{
    public override Element Render() => Memo(ctx =>
    {
        var (isOn, toggle) = ctx.UseToggler();
        return VStack(8,
            SubHeading("Custom hook: UseToggler"),
            Button(isOn ? "On" : "Off", toggle)
                .AutomationName("Toggle state"),
            TextBlock(isOn ? "State is on." : "State is off.")
                .Foreground(isOn ? Theme.SystemSuccess : Theme.SecondaryText)
        ).Padding(24);
    });
}

同样的 Hook 规则适用(参见 rules-of-reactor):无条件调用、在渲染顶部调用、每次都以相同顺序调用,绝不放在循环或条件分支里。分析器 REACTOR_HOOKS_001 对自定义 Hook 与内置 Hook 同样强制执行这些规则——它会沿着调用图走过扩展方法。

可观察对象桥接 — UseObservableTree

UseObservableTreeINotifyPropertyChanged 视图模型桥接进声明式的渲染循环。它在每一层深度上订阅属性变化,并在任意嵌套属性触发时重新渲染该组件。

先定义一个标准的 INPC 视图模型:

class SettingsViewModel : INotifyPropertyChanged
{
    private string _userName = "Alice";
    private bool _darkMode;
    private int _fontSize = 14;

    public string UserName
    {
        get => _userName;
        set { _userName = value; Notify(nameof(UserName)); }
    }
    public bool DarkMode
    {
        get => _darkMode;
        set { _darkMode = value; Notify(nameof(DarkMode)); }
    }
    public int FontSize
    {
        get => _fontSize;
        set { _fontSize = value; Notify(nameof(FontSize)); }
    }

    public event PropertyChangedEventHandler? PropertyChanged;
    private void Notify(string n) =>
        PropertyChanged?.Invoke(this, new(n));
}

然后在组件中用 UseObservableTree 绑定它:

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

Observable view model

这个视图模型就是一个实现了 INotifyPropertyChanged 的普通 C# 类。你直接修改它(vm.UserName = v),Reactor 就会重新渲染。这是为那些带着既有 MVVM 视图模型而来的团队准备的迁移桥接——它们无需任何修改就能工作。

UseObservable(source) 是浅层变体:它只订阅源对象自身的 PropertyChanged 事件,不包括嵌套对象。对于扁平的视图模型请优先用它——UseObservableTree 每次渲染都会遍历整张对象图,在深层对象树上这个开销会显现出来。

UseCollection

UseCollection 追踪一个 ObservableCollection<T>,并在项被添加、移除或集合被重置时重新渲染该组件:

class ObservableCollectionDemo : Component
{
    private record TaskItem(int Id, string Title);

    private static int _nextId = 3;
    private static readonly ObservableCollection<TaskItem> _tasks = new()
        { new TaskItem(1, "Review pull request"), new TaskItem(2, "Update documentation") };

    public override Element Render()
    {
        var tasks = UseCollection(_tasks);
        var (input, setInput) = UseState("");

        return VStack(12,
            SubHeading("UseCollection"),
            HStack(8,
                TextBox(input, setInput, placeholderText: "New task")
                    .AutomationName("New task")
                    .Width(200),
                Button("Add", () => {
                    if (!string.IsNullOrWhiteSpace(input))
                    { _tasks.Add(new TaskItem(_nextId++, input.Trim())); setInput(""); }
                })
            ),
            TextBlock($"{tasks.Count} tasks:").SemiBold(),
            VStack(4, tasks.Select((task, i) =>
                HStack(8,
                    // The index is fine for display; it must not become the key.
                    TextBlock($"{i + 1}. {task.Title}"),
                    Button("Remove", () => _tasks.Remove(task))
                        .AutomationName($"Remove {task.Title}")
                ).WithKey(task.Id.ToString())   // stable identity survives removal
            ).ToArray())
        ).Padding(24);
    }
}

Observable collection

该 Hook 返回 IReadOnlyList<T>——你在渲染中从它读取,在事件处理器中修改原始的 ObservableCollection。Reactor 订阅 CollectionChanged,并在任何修改时触发重新渲染。对于自己拥有的列表(在同一个组件中创建的集合),请优先用 UseState<ImmutableList<T>>——不可变的形态无需一个可观察的中间人,天然契合"由状态渲染"的模型。

热循环

流畅的修饰符链写起来顺手,但每走一步 with 都会分配出一个 ElementModifiers 克隆。对普通 UI 而言这个开销是无形的——一个带五个修饰符的按钮,意味着在点击处理器里多分配两条小记录。但对于一个每秒重渲染 30 次的 4,900 格网格,其内部循环中的单元格构造,这些克隆会主导整个分配画像。

逃生舱是直接构造 Element 及其 ElementModifiers 记录,完全跳过流畅链。 LayoutModifiersVisualModifiers 被特意设计为公开类型,正是为了让性能关键的代码能一次把它们建好,而不是由流畅链一步步重建。

static class HotLoopCells
{
    record Quote(bool IsUp);

    static readonly Brush GreenBrush = new SolidColorBrush(Colors.Green);
    static readonly Brush RedBrush = new SolidColorBrush(Colors.Red);

    public static void Build(string label, int r, int c)
    {
        var item = new Quote(IsUp: true);

        // Fluent — five clones per cell. Right tool for ordinary UI.
        var fluentCell = TextBlock(label)
            .FontSize(8)
            .Foreground(item.IsUp ? GreenBrush : RedBrush)
            .Padding(2, 1, 2, 1)
            .Grid(row: r, column: c);

        // Direct record initializer — one TextBlockElement, one ElementModifiers,
        // two bucket sub-records, one Attached dictionary. Use only when the
        // allocation cost shows up in profiles.
        var directCell = new TextBlockElement(label)
        {
            FontSize = 8,
            Modifiers = new ElementModifiers
            {
                Layout = new LayoutModifiers { Padding = new Thickness(2, 1, 2, 1) },
                Visual = new VisualModifiers { Foreground = item.IsUp ? GreenBrush : RedBrush },
            },
            Attached = new Dictionary<Type, object>(1)
            {
                [typeof(GridAttached)] = new GridAttached(r, c, 1, 1),
            },
        };

        _ = (fluentCell, directCell);
    }
}

注册契约。 工厂方法(TextBlock(...)Button(...) 等等)带有一个一次性的处理器注册动作,它在该工厂首次被调用时运行——协调器正是借此得知该为某条元素记录挂载哪个 WinUI 控件。直接构造记录(new TextBlockElement(...))有意绕过了工厂,因而也绕过了那次注册。这个写法是完全受支持的,但调用方必须确保该处理器在元素首次挂载之前已注册,否则协调器会在挂载时抛出 InvalidOperationException。三选一:

  • 在启动时调用一次对应的工厂(_ = TextBlock("");),或
  • 在启动时用 ReactorApp.RegisterAllBuiltIns() 选择接入完整的内置目录(最简单;除非你发布的是经过裁剪/NativeAOT、刻意只保留所用控件的二进制,否则这就是正确的选择),或
  • ControlRegistry.Register<TElement, TControl>(...)(控件支撑的元素)或 ControlRegistry.RegisterDecorator<TElement>(...)(包装/转发给子节点的装饰器支撑元素)显式注册特定处理器。

下面的参考应用在 Main 中、热循环运行之前调用了一次 ReactorApp.RegisterAllBuiltIns(),因此它的 new TextBlockElement(...) 单元格得以挂载,而无需为每个单元格付一次工厂调用。

适用负载形态。 请把它用在每次渲染含数百个以上元素的列表或网格中——行情表、日志表、可观测性仪表盘。不要把它用在普通界面上。除了内部单元格循环之外,流畅链在一切场合都是正确的工具。

权衡。 在 4,900 格压力网格上,它大致能把单元格构造的分配开销减半,但失去了流畅写法的顺手。直接形式在重构时更脆弱——改动一个字段要触碰一整块显式初始化器,而不是链上的一步。请把它限制在那个可识别的热循环里,文件其余部分保持流畅写法。至于流畅链为什么会分配,底层解释见 modifier-system

参考实现。 规范的前后对照位于 tests/stress_perf/StressPerf.Reactor(朴素版——流畅链,不了解的用户会写出的形态)与 tests/stress_perf/StressPerf.ReactorOptimized (惯用的性能调优变体)。同样的负载,可以并排做差异比对。

记忆化列表单元格

UseMemoCells 会跳过那些"项的值(以及所声明的依赖项)自上次渲染以来未变"的索引上的单元格构建。协调器的 ReferenceEquals 快捷路径意味着一个被复用的单元格不分配任何东西,并完全跳过差异比对。

class MemoCellsDemo : Component
{
    record Stock(string Symbol, double Price);

    static Element Cell(Stock item, ColorScheme scheme) =>
        TextBlock($"{item.Symbol} {item.Price:F2}")
            .Foreground(scheme == ColorScheme.Dark ? Theme.PrimaryText : Theme.SecondaryText);

    public override Element Render() => Memo(ctx =>
    {
        var stocks = new[] { new Stock("MSFT", 431.2), new Stock("GOOG", 176.5) };

        var scheme = ctx.UseColorScheme();
        var children = ctx.UseMemoCells(
            stocks,
            (item, i) => Cell(item, scheme),
            scheme);   // ← deps; framework invalidates on change

        return VStack(4, children);
    });
}

什么情况下它是对的锤子。 行情表、日志表、文件列表、大型只读网格——任何单元格内容是 T 加一小组已声明依赖项的纯函数的场合。

什么情况下它是错的锤子。 那些外观取决于焦点、拖拽、选中或悬停状态,而你又没把这些状态捕获进依赖项的行。当某个外部状态变化未被声明为依赖项时,Memo 会静默地渲染出陈旧内容——下面的分析器能抓到明显的情况,但通过辅助方法做的间接捕获它看不见。

三个重载:

重载 适用场景
UseMemoCells<T> 按项做值相等性比较。默认选择。
UseMemoCellsByKey<T, TKey> 项有稳定身份、但内部可变。按键做哈希、按值比较内容。重排后的键会通过协调器的键控子节点路径复用单元格。
UseMemoCellsByIndex<T> 数据源已经知道哪些索引变了。跳过逐单元格的相等性扫描;只有被点名的索引才会运行构建器。

gen2 注意事项。 Memo 是用更长寿的 gen1/gen2 保留,换掉了短命的 gen0 抖动。在一个应用中存在许多被记忆化的列表时,即使每次 tick 的字节数下降了,也可能加剧 gen2 压力。全面采用之前请先做性能剖析——ETW 驱动的采样见 perf-instrumentation,自顶向下的排查见 performance

编译期安全网。 配套的 Roslyn 分析器 REACTOR_HOOKS_007 会在构建器闭包捕获了某个未声明在依赖项列表中的值时发出警告。代码修复是"把缺失的捕获加入 deps"。通过中间方法做的间接捕获是已知的盲区——没有全程序分析,分析器看不穿一次方法调用(与 React 的 react-hooks/exhaustive-deps 是同一个盲区)。

回收边界上的兄弟机制。 UseMemoCells 运行在父组件的渲染边界上——它在列表组件重新渲染时跳过单元格构建。它在纯快速滚动期间帮不上忙,因为虚拟化列表会在不重新渲染父组件的情况下回收容器。那种场景请把行包进 Memo(key, factory)——那是可选的跨回收行缓存。两者可以组合:UseMemoCells 覆盖重新渲染,Memo(key, …) 覆盖滚动回收。

模式

编写自定义 Hook

自定义 Hook 让你把一组 Hook 组合藏在一个名字背后——UseDebouncedValueUseWindowScrollUsePersistedToggle——并在多个组件之间复用。它们就是普通的 RenderContext 扩展方法。 rules-of-reactor一页涵盖命名约定(Use* 前缀)与调用顺序纪律;hooks-internals一页解释了为什么自定义 Hook 能工作——Reactor 并不把它们与内置 Hook 区别对待,槽位表只是按顺序统计 Hook 调用。

带重试键的 ErrorBoundary

上面展示的重试模式可以扩展到异步失败、瞬时网络错误,以及任何状态应在用户确认时被重置的子树。请把这个重试键与一个持有在途任务的异步资源配对——递增该键会通过重新分配身份来取消上一次尝试,于是失败尝试中那个缓慢的获取不会与新的那个竞态。

从事件处理器做命令式聚焦

一个在用户名有效时把焦点移到密码框的登录表单,就是规范的 UseElementRef 形态——调用点参见上面的代码片段。同样的形态还覆盖:在快捷键之后聚焦某个列表项、通过 VirtualListRef 把一个 VirtualList 滚动到已知索引,以及在已知控件上启动一个 Composition 动画。更完整的焦点模型(焦点捕获、回车前进、自动化树)见 accessibilityfocus-and-input-internals

常见错误

把元素(或控件)缓存进模块级静态字段

// Don't — the FrameworkElement underneath is pool-managed.
static TextBox? _passwordField;

public override Element Render()
{
    var (pwd, setPwd) = UseState("");
    return TextBox(pwd, setPwd).Set(c => _passwordField = c);
}

// Elsewhere:
static void FocusPassword() => _passwordField?.Focus(FocusState.Programmatic);

协调器可能在渲染之间卸载、替换或池化底层控件。那个静态字段比上述每一个事件都活得更久,最终会指向一个已脱离或已被回收的实例。请使用 UseElementRef<T>——当控件未挂载时,引用的 .Current 为 null,并在重新挂载时被重新填充。

UseEffect 的清理中调用 setState

// Don't — setState inside cleanup runs during unmount/dispose.
UseEffect(() =>
{
    var sub = Subscribe();
    return () => { sub.Dispose(); setState(false); };  // ← stale state, may throw
}, []);

清理运行在组件已经不再是树的一部分之后;从那里调用 setState,要么空转(已释放的渲染上下文拒绝该调用),要么针对一份已经无关紧要的状态调度一次渲染。请把状态重置放进副作用函数体,或放进一个显式让父组件转换状态的事件处理器。清理顺序见 effects-scheduling

从父组件订阅控件事件却捕获了错误的 this

// Don't — `.Set` runs on every mount AND update, so each render adds another
// subscription, and the lambda captures the parent component instance that was
// current when the closure was created.
class WrongThisCaptureDemo : Component
{
    public override Element Render()
    {
        // Don't do this:
        // return Button("Load").Set(b => b.Loaded += (s, e) => this.OnChildLoaded());
        return Button("Load");
    }

    void OnChildLoaded() { }
}

那行被注释掉的 .Set(control => ...) 就是 bug:.Set 在每次挂载和更新时都会运行,因此每次渲染都会再加一个事件订阅。而 lambda 里的 this 是父组件,在闭包创建处被捕获,于是在父组件重新渲染之后,它仍强引用着一次陈旧渲染的组件实例。两种修复:把该处理器提升为父组件中一个稳定的 UseCallback 并通过 props 传下去;或用 .OnMount(...) 做一次性订阅,并显式提供清理。

.Set 是按控件支撑的元素生成的——ButtonElement.Set(Action<Button>)TextBoxElement.Set(Action<TextBox>) 等等。由 Component<T>() 返回的组件元素没有 .Set;要触达子组件的控件,请把 .Set 放在该子组件自己 Render 里的那个控件支撑元素上。)

明明有修饰符却去用 .Set

// Don't — Reactor exposes .RequestedTheme() as a modifier.
Button("Save", onSave).Set(c => c.RequestedTheme = ElementTheme.Dark);

修饰符会参与属性差异比对——下一次移除该修饰符的渲染会清除这个本地值,把属性交还给样式(或继承)所提供的那个值。.Set 是命令式且单向的;下一次渲染不会撤销它。有修饰符时请优先用修饰符;把 .Set 留给 Reactor 确实没有暴露的那些属性。REACTOR_THEME_003 分析器会抓 RequestedTheme 这个具体案例;同样的形态适用范围更广。

小贴士

ErrorBoundary 包裹第三方组件。 如果某个插件或市场组件在渲染期间抛异常,边界能阻止它拖垮你的整个应用。再配上一个由 UseState 计数器作键的"重试"按钮,让用户无需重启应用就能恢复。

记忆化之前先做性能剖析。 Memo 会引入记账成本,并给每棵子树多加一个 Hook 槽位。只有在性能剖析器上确实测到了重新渲染瓶颈时才去用它。大多数子树不用它也渲染得足够快。

优先用声明式修饰符,而不是 .Set() 每一次 .Set() 调用都是一个绕过 Reactor 属性差异比对的逃生舱。请把它留给 Reactor 未暴露的 WinUI 属性。如果你发现自己频繁伸手去拿 .Set,那么正确的下一步是提一个 Reactor 功能需求——框架应该为这种常见情况长出一个修饰符。

把视图模型放在组件之外。 把它们存为 static 字段,或通过上下文注入。在 Render() 里新建视图模型,会让每次渲染都分配一个新对象并丢掉全部状态,还会让 UseObservableTree 的订阅在每次渲染时都被打断。

深度只有一层时用 UseObservable UseObservableTree 每次变化都会遍历整张对象图。对于没有嵌套 INPC 对象的扁平视图模型,UseObservable 更便宜,产生的事件订阅也更少。

下一步

  • 数据系统 — 支持排序、筛选与行内编辑的 DataGrid
  • WinForms 互操作 — 在 WinForms 应用中承载 Reactor 组件
  • 图表 — 用折线图、柱状图、面积图、饼图与力导向图做数据可视化
  • Hook — 回顾 UseObservableTreeUseCollection 与自定义 Hook 所基于的那套 Hook 系统
  • 副作用与生命周期 — 与可观察数据绑定并行的副作用调度
  • Hook 内部机制 — 底层原理:Hook 到底是怎么存放它们的值的
  • 修饰符系统 — 底层原理:为什么流畅链会分配,以及直接初始化器的逃生舱换来了什么
  • Reactor 准则 — Hook 规则、渲染纯度,以及编写自定义 Hook 的注意事项