课程库 › 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 改信息
画图的十分钟能省后面一小时:所有"这时候按返回会怎样?"的问题,在图上一眼就有答案——答不出的,说明规则还没想清,别急着写代码。
装配线
动手区
卡住时的两根救命稻草:① 回去抄第 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 渲染"
📝 随堂测验
1. '转移图先于代码'的理由是?
2. success 状态的形状里没有 email 字段,这是哪条原则的落地?
3. 'submitting 时按 BACK 被无视'在实现里对应哪行代码?
4. reviewStep 重构演习展示的核心体验是?
本课小结 · 单元结课
- 装配线:画图 → 定型 → 心脏 → 守卫 → 工单 → 剧本试驾 → 点名重构
- 终检五问:不可能状态、守卫归位、自防齐全、纯度、点名重构
- 向导机器可直接进 useReducer:UI 薄到只剩发事件和渲染
这个单元从一坨互相拉扯的布尔出发,走到了一台可画图、可测试、可安全重构的机器:判别联合消灭了不可能状态,转移函数收编了全部规则,守卫和工单把条件与副作用各归其位,穷尽检查让变更有清单可循。更值钱的是那双"到处认亲"的眼睛——reducer、正则、TCP、游戏 AI,母题只有一个:有限的情况,明确的转移。下次接手一个"说不清现在处于什么情况"的组件时,你知道第一刀从哪里切了。
划选正文任意文字可高亮、批注或加入复习卡
讨论
载入中…