学径XUEJING · 个人学习平台通知设置

课程库 TypeScript 状态机 · 把「乱」写成「图」 › 第 6

项目课 —— 多步表单向导,一台完整的机器

项目课60 分钟

本课导读

收官实战:从零造一台多步表单向导状态机——报名流程三步走(填姓名 → 填邮箱 → 确认提交),外加提交中、成功、失败三个请求状态。这是前端最经典的状态机场景(产品设计 Track 的 Onboarding 单元管它叫分步引导,代码侧的实现就是本课),也是本单元全部武器的一次总动员:判别联合、转移函数、守卫、工单、穷尽检查。压轴是一场重构演习:给上线的机器加一个新状态,全程跟着编译器的点名走——体验一次"按图施工"的重构。预计 60 分钟。

说明

前置工具箱:全单元速查——布尔爆炸与"让不可能状态不可表示"(第 1 课);判别联合与判别字段(第 2 课);转移函数、return state 自防、assertNever(第 3 课);守卫住机器里、工单模式、不改输入(第 4 课);reducer 即转移函数(第 5 课——写完的机器可直接进 useReducer)。

第 0 步:先画图,再写代码

状态机项目的铁律:转移图先于代码。在纸上(或任何画图工具)画出:

  • 六个状态:nameStep(填姓名)→ emailStep(填邮箱)→ confirm(确认页)→ submitting(提交中)→ success / failure
  • 事件:NEXT(下一步)、BACK(上一步)、SUBMIT(确认提交)、RESOLVE / REJECT(请求结果)、RETRY(失败重试)
  • 关键规则:nameStep 姓名非空才能 NEXT(守卫);emailStep 邮箱含 @ 才能 NEXT(守卫);submitting 期间 BACK/SUBMIT 全部无视(自防);failure 可以 RETRY 回 submitting,也可以 BACK 回 confirm 改信息

画图的十分钟能省后面一小时:所有"这时候按返回会怎样?"的问题,在图上一眼就有答案——答不出的,说明规则还没想清,别急着写代码。

装配线

分步操作

动手区

TypeScript

卡住时的两根救命稻草:① 回去抄第 4 课工单版闸机的结构——本项目就是它的放大版;② 每个 case 只想一个问题:"这个状态收到这个事件,图上画的是什么?"图上没画的一律 return { next: state, effects: [] }

参考实现

自己跑通之前别展开——项目课的收获和你独立卡壳的时间成正比。

✅ 参考实现(心脏 + 外壳 + 剧本)
function transition(
    state: WizardState,
    event: WizardEvent
): { next: WizardState; effects: Effect[] } {
    switch (state.status) {
        case "nameStep":
            if (event.type === "TYPE_NAME") {
                return { next: { status: "nameStep", name: event.value }, effects: [] };
            }
            if (event.type === "NEXT") {
                if (state.name.trim() === "") {
                    return { next: state, effects: [{ kind: "SHOW_ERROR", reason: "请填写姓名" }] };
                }
                return { next: { status: "emailStep", name: state.name, email: "" }, effects: [] };
            }
            return { next: state, effects: [] };
        case "emailStep":
            if (event.type === "TYPE_EMAIL") {
                return { next: { status: "emailStep", name: state.name, email: event.value }, effects: [] };
            }
            if (event.type === "NEXT") {
                if (!state.email.includes("@")) {
                    return { next: state, effects: [{ kind: "SHOW_ERROR", reason: "邮箱格式不对" }] };
                }
                return { next: { status: "confirm", name: state.name, email: state.email }, effects: [] };
            }
            if (event.type === "BACK") {
                return { next: { status: "nameStep", name: state.name }, effects: [] };
            }
            return { next: state, effects: [] };
        case "confirm":
            if (event.type === "SUBMIT") {
                return {
                    next: { status: "submitting", name: state.name, email: state.email },
                    effects: [{ kind: "SEND_REQUEST", name: state.name, email: state.email }],
                };
            }
            if (event.type === "BACK") {
                return { next: { status: "emailStep", name: state.name, email: state.email }, effects: [] };
            }
            return { next: state, effects: [] };
        case "submitting":
            if (event.type === "RESOLVE") {
                return { next: { status: "success" }, effects: [] };
            }
            if (event.type === "REJECT") {
                return {
                    next: { status: "failure", name: state.name, email: state.email, message: event.message },
                    effects: [],
                };
            }
            return { next: state, effects: [] };   // BACK/SUBMIT 在此一律无视
        case "success":
            return { next: state, effects: [] };   // 终态
        case "failure":
            if (event.type === "RETRY") {
                return {
                    next: { status: "submitting", name: state.name, email: state.email },
                    effects: [{ kind: "SEND_REQUEST", name: state.name, email: state.email }],
                };
            }
            if (event.type === "BACK") {
                return { next: { status: "confirm", name: state.name, email: state.email }, effects: [] };
            }
            return { next: state, effects: [] };
        default:
            return assertNever(state);
    }
}

function runEffects(effects: Effect[]): void {
    for (const e of effects) {
        if (e.kind === "SHOW_ERROR") { console.log("  [提示] " + e.reason); }
        if (e.kind === "SEND_REQUEST") { console.log("  [请求] 提交 " + e.name + " / " + e.email); }
    }
}

const script: WizardEvent[] = [
    { type: "NEXT" },                              // 捣乱:空姓名
    { type: "TYPE_NAME", value: "俞晴" },
    { type: "NEXT" },
    { type: "TYPE_EMAIL", value: "yuqing.dev" },
    { type: "NEXT" },                              // 捣乱:没有 @
    { type: "TYPE_EMAIL", value: "yuqing@dev.com" },
    { type: "NEXT" },
    { type: "SUBMIT" },
    { type: "BACK" },                              // 捣乱:submitting 时按返回
    { type: "REJECT", message: "服务器超时" },
    { type: "RETRY" },
    { type: "RESOLVE" },
];

let state: WizardState = { status: "nameStep", name: "" };
for (const ev of script) {
    const r = transition(state, ev);
    console.log(ev.type + " -> " + r.next.status);
    runEffects(r.effects);
    state = r.next;
}

重构演习提示:加 | { status: "reviewStep"; name: string; email: string } 后,assertNever 立刻报 TS2345;把 confirm 的 SUBMIT 改为先进 reviewStep、reviewStep 的 NEXT 再进 submitting,报错清零、重跑剧本。整个过程你没有"想"要改哪里——编译器的清单就是施工图。

终检清单

  • 不可能状态:success 状态里能访问 email 吗?(不能——它压根没这个字段,这就是第 1 课原则的落地)
  • 守卫归位:姓名/邮箱的校验规则,机器外面还有第二份吗?
  • 自防齐全:剧本里三处捣乱剧情的输出都是"无视"或工单提示,而不是错误状态?
  • 纯度:transition 里有没有 console/随机数/日期?(都该在工单和外壳里)
  • 点名重构:reviewStep 演习做了吗?编译错误清单和你实际改动的位置一致吗?

交付物清单

  • 一张手画的转移图(第 0 步的产物,别跳过)
  • 跑通的向导机器(含守卫、工单、assertNever)+ 捣乱剧本的输出记录
  • reviewStep 重构演习的前后对比(加状态前的报错清单截图或记录)
  • 全部存进笔记。加餐(可选):把 transition 原样搬进一个 React useReducer 组件——你会发现 UI 层薄到只剩"发事件 + 按 status 渲染"

📝 随堂测验

随堂测验0 / 4 题正确

1. '转移图先于代码'的理由是?

2. success 状态的形状里没有 email 字段,这是哪条原则的落地?

3. 'submitting 时按 BACK 被无视'在实现里对应哪行代码?

4. reviewStep 重构演习展示的核心体验是?

本课小结 · 单元结课

  • 装配线:画图 → 定型 → 心脏 → 守卫 → 工单 → 剧本试驾 → 点名重构
  • 终检五问:不可能状态、守卫归位、自防齐全、纯度、点名重构
  • 向导机器可直接进 useReducer:UI 薄到只剩发事件和渲染

这个单元从一坨互相拉扯的布尔出发,走到了一台可画图、可测试、可安全重构的机器:判别联合消灭了不可能状态,转移函数收编了全部规则,守卫工单把条件与副作用各归其位,穷尽检查让变更有清单可循。更值钱的是那双"到处认亲"的眼睛——reducer、正则、TCP、游戏 AI,母题只有一个:有限的情况,明确的转移。下次接手一个"说不清现在处于什么情况"的组件时,你知道第一刀从哪里切了。

划选正文任意文字可高亮、批注或加入复习卡

讨论

载入中…