实践范例:登录¶
一个 Microsoft.UI.Reactor(Reactor)登录表单由四部分协同构成:带校验的输入、
受提交门控的状态、一次异步调用,以及一个错误展示面。下面的范例用两个
UseState Hook 和一个
UseMutation 把它们串起来 —— 没有视图模型,没有事件
处理类,也没有手写的 submitting / error 标志。
原语¶
| 关注点 | API |
|---|---|
| 逐字段状态 | UseState<string> |
| 异步提交 + 进行中 + 错误 | UseMutation |
| 提交按钮的禁用门控 | .IsEnabled(canSubmit) |
| 错误展示 | 条件式 Empty() 与 TextBlock 二选一 |
| 密码输入 | PasswordBox |
状态¶
// 只有这两个输入字段是需要亲手持有的状态。进行中标志与
// 最近一次错误归下面的 mutation 所有,而不是交给 UseState。
var (email, setEmail) = UseState("");
var (pwd, setPwd) = UseState("");
两次 UseState 调用 —— 邮箱与密码。其他任何东西都不需要自己的 Hook
槽位:进行中标志与最近一次错误由 mutation 持有,不会被复制成组件状态。
异步提交¶
// UseMutation 持有 IsPending / Error / LastResult,并在卸载时取消
// 进行中的调用。mutator 失败时抛异常;Hook 会把该异常
// 捕获进 signIn.Error 并触发重渲染。
var signIn = UseMutation<(string Email, string Password), bool>(
mutator: async (input, ct) =>
{
await Task.Delay(800, ct); // pretend API call
// 哨兵字符串足够长,能通过本地校验,这样失败分支
// 才真的能从表单里走到。
if (input.Password == "wrongpassword")
throw new InvalidOperationException("Invalid credentials.");
return true;
});
UseMutation 是 UseResource 在写入侧的对偶。
它返回一个 Mutation<TInput, TResult> 句柄,该句柄跨渲染稳定,
并且恰好暴露提交按钮所需的三个东西:IsPending、Error 与 LastResult。mutator
接收一个会在卸载时触发的 CancellationToken,因此用户在提交途中导航离开时,
进行中的提交会被取消,而不是对着一个已死的组件
执行完成。
失败通过抛异常来传达。Hook 捕获该异常、
存到 Error 上并重渲染 —— 这也是为什么这个范例里既没有
try/finally,也没有 setSubmitting(false)。
注意: 不要在 mutation 旁边再引入
var (submitting, setSubmitting) = UseState(false)。 对「是否正在提交」的两处真相,在一通调用被取消或第二次提交重叠的 那一刻就会漂移 ——IsPending是进行中调用的计数,本来就已经覆盖了这两种情况。
逐键校验¶
// 本地校验在每次按键时运行。表单有效之前提交按钮保持
// 禁用;进行中的提交由同一个谓词门控,读取 mutation 自己的
// 进行中标志。
var emailValid = email.Contains('@') && email.Contains('.');
var pwdValid = pwd.Length >= 8;
var canSubmit = emailValid && pwdValid && !signIn.IsPending;
表单在每次渲染时从原始状态推导出 emailValid 与 pwdValid —— 没有防抖,
也没有单独的校验轮次。在 Reactor 里逐键重渲染没有问题;这些工作都是纯 C#,
并且协调器会跳过没有变化的槽位。
渲染¶
// RunAsync 返回的 task 在 mutator 抛异常时会进入 faulted 状态,因此
// 直接丢弃它会让该错误无人观察。Hook 已经把异常
// 捕获进 signIn.Error —— 也就是下面渲染的那个 —— 所以这里的 await
// 纯粹是为了观察该 task。
async Task SubmitAsync()
{
// 精确捕获:这里要处理的是上面 mutator 因
// "wrongpassword" 而抛出的凭据失败,而 signIn.Error(下面渲染)就是它的
// 展示路径。在这里捕获 Exception 会连无关的错误一起藏掉。
try { await signIn.RunAsync((email, pwd)); }
catch (InvalidOperationException) { /* displayed via signIn.Error */ }
}
return VStack(12,
Heading("Sign in"),
TextBox(email, setEmail, placeholderText: "you@example.com",
header: "Email").Width(280),
PasswordBox(pwd, setPwd, placeholderText: "8+ characters")
.AutomationName("Password"),
signIn.Error is null
? Empty()
: TextBlock(signIn.Error.Message).Foreground(Theme.SystemCritical),
// SubmitAsync 已经在上面吞掉了错误,因此这次的丢弃
// 不会留下无人观察的异常。
Button(signIn.IsPending ? "Signing in…" : "Sign in",
() => _ = SubmitAsync())
.AutomationName("Sign in")
.IsEnabled(canSubmit)
).Padding(20).Width(320);

canSubmit 谓词在单次渲染中为按钮把关 ——
禁用按钮就够了;在 mutator 内部再放一个分析器会标记的守卫,反而会在重渲染竞态中
被触发两次。signIn.IsPending 同时决定
转圈时的标签("Signing in…")与禁用状态,因此进行中的提交不会被一次
回车再次触发。
SubmitAsync 观察 signIn.RunAsync((email, pwd)) 返回的 task,
同时把渲染用的错误状态交给 mutation 持有,于是 UI 读的是那个
句柄,而不是复制一套错误标志。
提示¶
不要动用视图模型。 一个 30 行的登录表单不需要它;
两个 UseState Hook 加那个 mutation 就是视图模型。类继承层次的代价
就是维护它的代价。
让 mutation 持有进行中与错误状态。 你在 IsPending 旁边每加一个 UseState 布尔值,
就多了一台必须与 Hook 内部状态保持同步的状态机。当你需要在提交成功后
做点什么(导航、清空表单)时,请使用 MutationOptions.OnSuccess,
而不是在副作用里轮询 LastResult。
在按钮处把关,而不是在处理函数内部。 禁用的按钮只是一次渲染检查; 而 mutator 内部的守卫是在用户已经按下按钮、UI 看起来已经就绪之后才运行。 两层都算良好习惯,但按钮这道门才是承重的。
请用 PasswordBox,而不是加个十六进制样式的 TextBox。
该控件实现了粘贴不露出、自动填充以及无障碍对等体;
在用户代码里重造会丢掉这些。