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

规则索引¶
| 规则 | 分析器 | 所在页面 |
|---|---|---|
| Hook 顺序保持稳定 | REACTOR_HOOKS_001、REACTOR_HOOKS_002 |
Hook |
| 只能在 Render 中调用 Hook | REACTOR_HOOKS_005 |
Hook |
| 依赖必须稳定 | REACTOR_HOOKS_004 |
Hook |
| 防抖命令需要 UseCommand | REACTOR_HOOKS_009 |
命令 |
| 渲染是纯的 | (约定) | 组件 |
| 列表需要稳定的键 | REACTOR_DSL_001..004 |
集合 |
| 用主题令牌而非字面量 | REACTOR_THEME_001、REACTOR_THEME_004 |
主题令牌 |
| 轻量样式化 | REACTOR_THEME_002 |
样式 |
| 无障碍名称 | REACTOR_A11Y_001..004 |
无障碍 |
下面每一条规则对应索引中的一行,并给出「之前/之后」的 形态以及分析器会在哪里捕获。
1. Hook 顺序跨渲染保持稳定¶
Hook 把状态存放在组件 Hook 列表中的槽位索引处。
协调器每次渲染都按序号遍历该列表 —— 因此如果
UseState 在第 1 次渲染时位于槽位 0、第 2 次渲染时位于槽位 1,
状态就会迁移到错误的槽位,值会静默损坏。
规则: 在 Render() 顶部无条件地调用每一个 Hook,
顺序相同,每次都如此。不要在 Hook 调用外面套 if、for 或
try/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
必须是记录的主键,而不是数组索引。如果条目实现了
IReactorKeyed,ForEach 会替你填好键 —— 只有当你想要的
不是 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 是稳定的,依赖也必须稳定¶
UseState、UseReducer 与 UsePersisted 返回的 Action<T> setter
跨渲染保持同一个委托身份。你可以放心地在 UseEffect 的清理函数、
被捕获的事件处理函数或某个 Task 里闭包捕获它 ——
捕获到的引用就是那个活的
setter。
依赖数组则不能这么说。一个新分配的
数组或新分配的记录作为 deps 传入时,每次渲染的引用都不同,
于是副作用每次都会重新触发:
// Wrong:
UseEffect(Setup, new[] { name, version }); // freshly-allocated array
// REACTOR_HOOKS_004 flags this.
规则: 通过 UseEffect/UseMemo/UseCallback 的 params 重载
传入 deps,而不要传新分配的数组。分析器能捕获常见的形态;
对于分析器看不到的情况(内联分配的 Tuple<...>),请先把这些依赖赋给一个局部变量。
分析器: REACTOR_HOOKS_004(不稳定的依赖)、
REACTOR_HOOKS_007(UseMemoCells 构建器漏掉了被捕获的
依赖)。
完整内容见 Hook。
5. 主题感知的修饰符应当接受令牌,而不是字面量¶
.Background、.Foreground 与 .WithBorder 接受一个画笔。十六进制
字面量在演示里能用,但用户一翻转主题就崩了 ——
字面量被锁死在你打下的那个值上,于是
品牌蓝按钮在已经变暗的背景上依然是蓝色,对比度
随之崩塌。
规则: 给任何主题感知的修饰符传 Theme.* 令牌(自定义 XAML 资源键则用
Theme.Ref("CustomKey"))。十六进制字面量只留给
那些颜色有意不随主题变化的情形(品牌标识、打印预览)—— 并且
留一条注释说明这一点。
分析器: REACTOR_THEME_001(主题感知修饰符上硬编码颜色字符串)
以及 REACTOR_THEME_004(硬编码的
Brush/Color 对象绕过了主题令牌)。
完整内容见 主题令牌。
参考¶
| 规则 | 分析器 | 页面所在位置 |
|---|---|---|
| Hook 顺序保持稳定 | REACTOR_HOOKS_001、REACTOR_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_001、REACTOR_THEME_004 |
主题令牌 |
| 用轻量样式化,而不是隐式资源 | REACTOR_THEME_002 |
样式 |
RequestedTheme 是渲染输入,不是 setter |
REACTOR_THEME_003 |
样式 |
| 公共 API 上要有 XML 文档 | REACTOR_DOC_001 |
(框架代码) |
<see cref="..."/> 能解析 |
CS1574 |
(框架代码) |
| 交互元素要有无障碍名称 | REACTOR_A11Y_001..004 |
无障碍 |
提示¶
在 CI 中把分析器警告当作构建错误。 每一条有分析器的规则, 已知误报率都接近零;压制一条警告的代价很小, 而分析器本可捕获的回归代价很大。
「纯渲染」规则是约定,不是强制的。 没有 分析器能捕获它;评审与快照测试才是安全网。 测试 页讲解了让纯度可见的快照模式。
.WithKey 很廉价;只要列表会重排就用它。 即使
分析器没有标出遗漏,缺少键也是那种最容易
交付到客户手上的缺陷(在列表规模小时它不可见,规模大时
则是灾难性的)。