Skip to content

实践范例:登录

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

UseMutationUseResource 在写入侧的对偶。 它返回一个 Mutation<TInput, TResult> 句柄,该句柄跨渲染稳定, 并且恰好暴露提交按钮所需的三个东西:IsPendingErrorLastResult。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;

表单在每次渲染时从原始状态推导出 emailValidpwdValid —— 没有防抖, 也没有单独的校验轮次。在 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 该控件实现了粘贴不露出、自动填充以及无障碍对等体; 在用户代码里重造会丢掉这些。

后续阅读

  • 异步资源 —— UseMutation 的完整 选项面:乐观更新、缓存失效、回调。
  • 表单 —— 本范例所取用的完整输入 + 校验面。
  • 副作用 —— 用户在提交途中导航离开时的取消模式。
  • 实践范例:模态对话框 —— 把本范例与一个 「忘记密码?」模态框配对。
  • 无障碍 —— 一旦添加更多字段就需要遵守的焦点顺序规则。
  • 实践范例索引 —— 回到图库。