Skip to content

Reactor 的规则

Microsoft.UI.Reactor(Reactor)的渲染循环有一小组不变式。其中大多数 由分析器强制执行,因此违反会在构建时以 特定代码浮现;其余则是框架期望你遵守的约定。 本页把它们列出来,标出对应的分析器(若存在),并给出 「之前/之后」的对照,好让捕获点看得见。

五条核心规则:

  1. Hook 顺序跨渲染保持稳定。
  2. 渲染函数是纯的。
  3. 列表需要稳定的键。
  4. Hook 返回的 setter 是稳定的;依赖也必须稳定。
  5. 主题感知的修饰符应当接受令牌,而不是字面量。
// REACTOR_HOOKS_001 — hooks must run unconditionally on every render.
// Wrapping a hook in `if` shifts the hook indices when the branch flips,
// and the next render reads slot N expecting `UseEffect` but finds
// `UseState`. The HookOrderException it raises is loud, but the bug
// can ship if the conditional is rarely true.
class HookOrderBad : Component
{
    public bool ShouldCount;

    public override Element Render()
    {
        if (ShouldCount)
        {
            var (count, _) = UseState(0);             // REACTOR_HOOKS_001
            return TextBlock($"Count: {count}");
        }
        return TextBlock("No counter.");
    }
}

最简应用 —— rules-of-reactor 文档应用外壳

规则索引

规则 分析器 所在页面
Hook 顺序保持稳定 REACTOR_HOOKS_001REACTOR_HOOKS_002 Hook
只能在 Render 中调用 Hook REACTOR_HOOKS_005 Hook
依赖必须稳定 REACTOR_HOOKS_004 Hook
防抖命令需要 UseCommand REACTOR_HOOKS_009 命令
渲染是纯的 (约定) 组件
列表需要稳定的键 REACTOR_DSL_001..004 集合
用主题令牌而非字面量 REACTOR_THEME_001REACTOR_THEME_004 主题令牌
轻量样式化 REACTOR_THEME_002 样式
无障碍名称 REACTOR_A11Y_001..004 无障碍

下面每一条规则对应索引中的一行,并给出「之前/之后」的 形态以及分析器会在哪里捕获。

1. Hook 顺序跨渲染保持稳定

Hook 把状态存放在组件 Hook 列表中的槽位索引处。 协调器每次渲染都按序号遍历该列表 —— 因此如果 UseState 在第 1 次渲染时位于槽位 0、第 2 次渲染时位于槽位 1, 状态就会迁移到错误的槽位,值会静默损坏。

规则:Render() 顶部无条件地调用每一个 Hook, 顺序相同,每次都如此。不要在 Hook 调用外面套 iffortry/catch,也不要提前返回。

分析器: REACTOR_HOOKS_001(条件式 Hook 调用)、 REACTOR_HOOKS_002(在提前返回守卫之后的 Hook),以及 REACTOR_HOOKS_005(在 Render() 重写或 Use* 辅助方法之外调用 Hook)。

之前:

// REACTOR_HOOKS_001 — hooks must run unconditionally on every render.
// Wrapping a hook in `if` shifts the hook indices when the branch flips,
// and the next render reads slot N expecting `UseEffect` but finds
// `UseState`. The HookOrderException it raises is loud, but the bug
// can ship if the conditional is rarely true.
class HookOrderBad : Component
{
    public bool ShouldCount;

    public override Element Render()
    {
        if (ShouldCount)
        {
            var (count, _) = UseState(0);             // REACTOR_HOOKS_001
            return TextBlock($"Count: {count}");
        }
        return TextBlock("No counter.");
    }
}

之后:

class HookOrderGood : Component
{
    public bool ShouldCount;

    public override Element Render()
    {
        // Hook 总会运行;条件被移进了渲染输出里。
        var (count, _) = UseState(0);
        return ShouldCount
            ? TextBlock($"Count: {count}")
            : TextBlock("No counter.");
    }
}

Hook 无条件运行;分支移进了 Render() 返回的元素树中。UI 相同,Hook 顺序稳定。

完整内容见 Hook

2. 渲染函数是纯的

组件每次重渲染时 Render() 都会运行 —— 在动画或输入之下, 每秒可能运行很多次。Render() 内部的副作用 (写静态计数器、调用日志器、打开文件)会在每次渲染时触发, 包括那些用来捕获缺陷的开发模式双渲染。副作用正确的去处是 UseEffect, 它在每次渲染提交、元素树落实之后运行一次。

规则: Render 读取状态并返回元素。它不做变更、不做 I/O、 不发遥测。副作用放进 UseEffect

之前:

// Render must be pure. Side effects (file I/O, mutation of static state,
// timers) belong inside UseEffect, which runs after the render commits.
// A logger call inside Render mounts will fire on every re-render,
// including ones triggered by the debugger — and it makes snapshot tests
// flaky because the rendered output now depends on a side effect.
static class TelemetryBad
{
    public static int CardRenders;
}

class CardBad : Component
{
    public override Element Render()
    {
        TelemetryBad.CardRenders++;                    // side effect in Render
        return TextBlock("Card");
    }
}

之后:

static class Telemetry
{
    public static int CardRenders;
}

class CardGood : Component
{
    public override Element Render()
    {
        UseEffect(() =>
        {
            Telemetry.CardRenders++;
            return () => { };
        });
        return TextBlock("Card");
    }
}

计数器仍然在每次渲染时递增,但这一次是在 副作用里做的 —— 这意味着对 CardGood 做快照测试时不再有 「渲染时副作用」的缺陷;副作用在测试检查 元素树之后才触发。完整内容见 副作用组件

3. 列表需要稳定的键

ForEach / ListView / Select(...).ToArray() 中的条目 重排时,协调器会对新旧元素树做差异比对。 没有键的话,它按位置比对 —— 于是换了位置的那一行 会拿到原本属于前一行的本地状态(焦点、滚动偏移、进行中的 编辑)。有了 .WithKey(id),协调器按键匹配,把 正确的状态移到正确的行上。

规则: 在类列表结构中产生的每一个元素都要带上 .WithKey(stableId),其值跨重渲染持续存在。这个 id 必须是记录的主键,而不是数组索引。如果条目实现了 IReactorKeyedForEach 会替你填好键 —— 只有当你想要的 不是 item.Key 时才需要写。

分析器: REACTOR_DSL_001(动态列表项缺少 .WithKey)、 REACTOR_DSL_002(非稳定的 .WithKey)、 REACTOR_DSL_003(类型化集合的 keySelector 从未按条目设置键),以及 REACTOR_DSL_004.WithKey 只是重述了 ForEach 已为 IReactorKeyed 条目提供的键)。

之前:

// A list reorder without keys forces the reconciler to walk both lists in
// order and reuse slot 0 for whatever new item lands first. Local state
// (focus, scroll position, in-flight edits) gets attached to the wrong
// row. WithKey on each child binds state to identity rather than slot.
class TodoListBad : Component
{
    public TodoItem[] Items = System.Array.Empty<TodoItem>();

    public override Element Render() => VStack(4,
        Items.Select(i =>
            // No .WithKey — reorder is destructive.
            TextBox(i.Title, title => Rename(i, title), header: i.Id.ToString())
        ).ToArray()
    );

    void Rename(TodoItem item, string title)
    {
        var index = System.Array.IndexOf(Items, item);
        if (index >= 0)
            Items[index] = item with { Title = title };
    }
}
public record TodoItem(int Id, string Title);

之后:

class TodoListGood : Component
{
    public TodoItem[] Items = System.Array.Empty<TodoItem>();

    public override Element Render() => VStack(4,
        Items.Select(i =>
            TextBox(i.Title, title => Rename(i, title), header: i.Id.ToString())
                .WithKey(i.Id.ToString())                  // stable identity
        ).ToArray()
    );

    void Rename(TodoItem item, string title)
    {
        var index = System.Array.IndexOf(Items, item);
        if (index >= 0)
            Items[index] = item with { Title = title };
    }
}

完整内容见 集合协调

4. setter 是稳定的,依赖也必须稳定

UseStateUseReducerUsePersisted 返回的 Action<T> setter 跨渲染保持同一个委托身份。你可以放心地在 UseEffect 的清理函数、 被捕获的事件处理函数或某个 Task 里闭包捕获它 —— 捕获到的引用就是那个活的 setter。

依赖数组则不能这么说。一个新分配的 数组或新分配的记录作为 deps 传入时,每次渲染的引用都不同, 于是副作用每次都会重新触发:

// Wrong:
UseEffect(Setup, new[] { name, version });    // freshly-allocated array
// REACTOR_HOOKS_004 flags this.
// Right:
UseEffect(Setup, name, version);              // params overload — items
                                              // compared by value.

规则: 通过 UseEffectUseMemoUseCallbackparams 重载 传入 deps,而不要传新分配的数组。分析器能捕获常见的形态; 对于分析器看不到的情况(内联分配的 Tuple<...>),请先把这些依赖赋给一个局部变量。

分析器: REACTOR_HOOKS_004(不稳定的依赖)、 REACTOR_HOOKS_007UseMemoCells 构建器漏掉了被捕获的 依赖)。

完整内容见 Hook

5. 主题感知的修饰符应当接受令牌,而不是字面量

.Background.Foreground.WithBorder 接受一个画笔。十六进制 字面量在演示里能用,但用户一翻转主题就崩了 —— 字面量被锁死在你打下的那个值上,于是 品牌蓝按钮在已经变暗的背景上依然是蓝色,对比度 随之崩塌。

规则: 给任何主题感知的修饰符传 Theme.* 令牌(自定义 XAML 资源键则用 Theme.Ref("CustomKey"))。十六进制字面量只留给 那些颜色有意不随主题变化的情形(品牌标识、打印预览)—— 并且 留一条注释说明这一点。

分析器: REACTOR_THEME_001(主题感知修饰符上硬编码颜色字符串) 以及 REACTOR_THEME_004(硬编码的 Brush/Color 对象绕过了主题令牌)。

// Don't:
Button("Save", () => { }).Background("#0066CC");   // REACTOR_THEME_001
// Do:
Button("Save", () => { }).Background(Theme.Accent);

完整内容见 主题令牌

参考

规则 分析器 页面所在位置
Hook 顺序保持稳定 REACTOR_HOOKS_001REACTOR_HOOKS_002 Hook
只能在 Render 中调用 Hook REACTOR_HOOKS_005 Hook
依赖必须稳定 REACTOR_HOOKS_004 Hook
UseResource 的拉取器是幂等的 REACTOR_HOOKS_006 异步资源
UseMemoCells 构建器必须闭包捕获依赖 REACTOR_HOOKS_007 Hook
防抖命令应经由 UseCommand 路由 REACTOR_HOOKS_009 命令
渲染必须是纯的 (约定 —— 无分析器) 组件副作用
列表需要稳定的键 REACTOR_DSL_001..004 集合协调
主题感知修饰符应当接受令牌 REACTOR_THEME_001REACTOR_THEME_004 主题令牌
用轻量样式化,而不是隐式资源 REACTOR_THEME_002 样式
RequestedTheme 是渲染输入,不是 setter REACTOR_THEME_003 样式
公共 API 上要有 XML 文档 REACTOR_DOC_001 (框架代码)
<see cref="..."/> 能解析 CS1574 (框架代码)
交互元素要有无障碍名称 REACTOR_A11Y_001..004 无障碍

提示

在 CI 中把分析器警告当作构建错误。 每一条有分析器的规则, 已知误报率都接近零;压制一条警告的代价很小, 而分析器本可捕获的回归代价很大。

「纯渲染」规则是约定,不是强制的。 没有 分析器能捕获它;评审与快照测试才是安全网。 测试 页讲解了让纯度可见的快照模式。

.WithKey 很廉价;只要列表会重排就用它。 即使 分析器没有标出遗漏,缺少键也是那种最容易 交付到客户手上的缺陷(在列表规模小时它不可见,规模大时 则是灾难性的)。

后续阅读

  • Hook —— 规则 1 与规则 4 被实践得最多的地方。
  • 组件 —— 深入讲解渲染的纯度。
  • 协调 —— 为什么键在协调器层面如此重要。
  • 主题令牌 —— 规则 5 的令牌目录。
  • 无障碍 —— A11Y_001..004 规则。