适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。
核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。
本课产出: 能用状态机替代多个布尔;能设计不会出现非法组合的状态;能用枚举和 useReducer 实现状态机;能画出状态转移图;能处理异步流程、多步骤流程、复杂 UI 交互的状态机。
一句话预览: 派生解决"数据怎么算",状态机解决"状态怎么组织"。当你发现自己在维护 isLoading、isError、isSuccess 三个布尔时,就该用状态机了。
一、本课目标
学完本课,你应该能:
- 说清楚状态机的三个要素:状态集合、转移规则、事件。
- 识别什么时候该用状态机:多个布尔互斥、状态组合爆炸。
- 用"不可能状态原则"设计状态机,让非法状态无法表示。
- 用枚举和
useReducer两种方式实现状态机。 - 画出状态转移图,用可视化辅助设计。
- 处理异步流程、多步骤流程、复杂 UI 交互的状态机。
- 用状态机组织副作用,避免竞态和非法转移。
- 识别并修复 10 种常见反模式。
二、从一个真实痛点说起
场景:一个文件上传组件
需求:选择文件、上传、显示进度、成功或失败、可取消、可重试。
一个开发者这样写:
jsx
function FileUpload() {
const [isUploading, setIsUploading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)
const [isCancelled, setIsCancelled] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState(null)
async function upload(file) {
setIsUploading(true)
setIsError(false)
setIsSuccess(false)
setIsCancelled(false)
try {
await api.upload(file, p => setProgress(p))
setIsUploading(false)
setIsSuccess(true)
} catch (e) {
setIsUploading(false)
setIsError(true)
setError(e.message)
}
}
function cancel() {
setIsCancelled(true)
setIsUploading(false)
}
return (
<div>
{isUploading && <ProgressBar value={progress} />}
{isSuccess && <p>上传成功</p>}
{isError && <p>上传失败:{error}</p>}
{isCancelled && <p>已取消</p>}
<button onClick={() => upload(file)}>上传</button>
<button onClick={cancel}>取消</button>
</div>
)
}
这段代码能跑,但有严重问题:
- 五个布尔,理论上 32 种组合。 但只有 5 种合法(idle、uploading、success、error、cancelled)。其余 27 种是非法状态。
- 非法组合会出现。 比如
isUploading && isSuccess同时为 true(上传成功后忘了清isUploading),界面会同时显示进度条和成功提示。 - 每次操作要手动重置多个状态。 忘记重置一个,就出现非法组合。
- 取消逻辑不完整。
cancel只设了isCancelled和isUploading,没清isSuccess、isError,可能出现多个状态同时为 true。 - 无法推理。 32 种组合,你无法保证每种都是合法的。
用状态机重写:
jsx
function FileUpload() {
const [status, setStatus] = useState("idle")
// "idle" | "uploading" | "success" | "error" | "cancelled"
const [progress, setProgress] = useState(0)
const [error, setError] = useState(null)
async function upload(file) {
setStatus("uploading")
setProgress(0)
try {
await api.upload(file, p => setProgress(p))
setStatus("success")
} catch (e) {
if (e.name === "AbortError") {
setStatus("cancelled")
} else {
setError(e.message)
setStatus("error")
}
}
}
return (
<div>
{status === "uploading" && <ProgressBar value={progress} />}
{status === "success" && <p>上传成功</p>}
{status === "error" && <p>上传失败:{error}</p>}
{status === "cancelled" && <p>已取消</p>}
<button
disabled={status === "uploading"}
onClick={() => upload(file)}
>
上传
</button>
{status === "uploading" && (
<button onClick={cancel}>取消</button>
)}
</div>
)
}
一个状态,5 种取值,天然互斥。不可能出现"上传中且成功"这种非法状态。
这就是状态机的价值:让非法状态无法表示。
三、什么是状态机
状态机的定义
状态机(State Machine)是一种数学模型,描述一个系统在有限个状态之间的转移。
三个要素:
- 状态集合(States): 所有可能的取值。
- 转移规则(Transitions): 从哪个状态能到哪个状态。
- 事件(Events): 触发转移的动作。
一个简单的状态机
以文件上传为例:
text
状态集合:idle, uploading, success, error, cancelled
转移规则:
idle ──upload()──▶ uploading
uploading ──resolve──▶ success
uploading ──reject──▶ error
uploading ──cancel()──▶ cancelled
error ──retry()──▶ uploading
cancelled ──upload()──▶ uploading
success ──reset()──▶ idle
画成状态转移图:
text
upload() resolve
idle ──────────────▶ uploading ─────────▶ success
│ │ │
reject │ │ cancel() │ reset()
▼ ▼ │
error cancelled │
│ │ │
retry() upload() │
│ │ │
└──────┴──────────────┘
│
▼
idle
一旦画出这个图,代码结构就清晰了。
状态机的三个关键概念
概念一:有限状态。
状态的数量是有限的、明确的。不是"很多种可能",而是"这 5 种"。
概念二:互斥。
同一时刻,只有一个状态。不是"isUploading 和 isSuccess 同时为 true",而是"当前是 uploading 或 success"。
概念三:明确转移。
从一个状态到另一个状态,必须有明确的事件触发。不是"任何时候都能改任何状态",而是"只有 upload() 能从 idle 到 uploading"。
状态机 vs 多个布尔
| 维度 | 多个布尔 | 状态机 |
|---|---|---|
| 状态数 | 2^n(组合爆炸) | n(线性) |
| 非法状态 | 可能 | 不可能 |
| 代码可读性 | 差 | 好 |
| 状态转移 | 隐式 | 显式 |
| 调试 | 困难 | 容易 |
| 可视化 | 难 | 容易 |
n 个布尔有 2^n 种组合,但只有 n+1 种合法(加上初始态)。状态机把 2^n 降到 n。
一张表看清差距
假设你有 4 个布尔:
jsx
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isCancelled, setIsCancelled] = useState(false)
| 维度 | 4 个布尔 | 状态机 |
|---|---|---|
| 理论组合 | 16 | 5 |
| 合法组合 | 5 | 5 |
| 非法组合 | 11 | 0 |
| 需要重置的状态 | 4 | 1 |
| 判断逻辑 | 复杂 | 简单 |
4 个布尔就有 11 种非法组合。每增加一个布尔,非法组合翻倍。状态机把这个数字压到 0。
什么时候该用状态机
信号一:多个布尔互斥。
jsx
// 这个信号很明确:三个布尔,最多只有一个为 true
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
// → 改成状态机
const [status, setStatus] = useState("idle")
信号二:状态组合爆炸。
当你发现"如果 A 为 true 且 B 为 false 且 C 为 true"这种条件判断时,就该用状态机。
信号三:状态转移复杂。
当状态之间的转移规则复杂,用多个布尔很难表达清楚时,用状态机。
信号四:需要可视化。
当你想画出状态转移图来和产品经理讨论时,用状态机。
信号五:需要防止非法状态。
当非法状态会导致 bug 时,用状态机从结构上杜绝。
信号六:需要测试状态转移。
当你想独立测试状态转移逻辑时,用 useReducer,reducer 是纯函数,容易测试。
什么时候不该用状态机
信号一:只有一个布尔。
jsx
// 一个布尔,用状态机是过度设计
const [isOpen, setIsOpen] = useState(false)
信号二:状态之间独立。
jsx
// 主题和语言独立,不需要状态机
const [theme, setTheme] = useState("light")
const [locale, setLocale] = useState("zh")
信号三:状态简单。
jsx
// 一个计数器的状态,不需要状态机
const [count, setCount] = useState(0)
原则:状态机解决"互斥状态的组织",不是所有状态都要状态机。
四、不可能状态原则
什么是不可能状态原则
让非法状态在结构上无法表示,而不是靠运行时检查。
两个层次的实现
层次一:枚举值(运行时保证)。
jsx
const [status, setStatus] = useState("idle")
// 只能是 "idle" | "loading" | "success" | "error"
优点: 简单,不需要 TypeScript。
缺点: 运行时才能发现拼写错误,setStatus("loadingg") 不会报错。
层次二:TypeScript 判别联合(编译时保证)。
ts
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string }
优点: 编译时检查,拼写错误立即发现,数据关联明确。
缺点: 需要 TypeScript。
判别联合的强大之处
ts
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string }
这个类型定义保证:
success状态必有data。error状态必有error。idle和loading没有额外数据。- 不可能出现"成功但没有数据"。
- 不可能出现"错误但没有错误信息"。
- 不可能出现
status拼错。
消费这个状态时,TypeScript 会强制你处理所有分支:
tsx
function render(state: State) {
switch (state.status) {
case "idle":
return null
case "loading":
return <Spinner />
case "success":
return <Profile user={state.data} /> // TypeScript 知道有 data
case "error":
return <Error message={state.error} /> // TypeScript 知道有 error
}
}
如果你漏了一个 case,TypeScript 会报错。 这就是"不可能状态原则"的最高境界。
用 never 强制完备性
ts
function assertNever(x: never): never {
throw new Error(`未处理的状态: ${JSON.stringify(x)}`)
}
function render(state: State) {
switch (state.status) {
case "idle": return null
case "loading": return <Spinner />
case "success": return <Profile user={state.data} />
case "error": return <Error message={state.error} />
default: return assertNever(state)
}
}
如果漏了 case,assertNever 会让 TypeScript 报错。 这是强制完备性的技巧。
为什么有用? 当状态集合增加时(比如新增 cancelled),所有 switch 都会报错,提醒你处理新状态。编译器帮你找到所有需要更新的地方。
用单个状态对象而非多个状态
jsx
// ❌ 多个状态,可能非法组合
const [status, setStatus] = useState("idle")
const [data, setData] = useState(null)
const [error, setError] = useState(null)
// 可能出现 status === "success" 但 data === null
// ✅ 单个状态对象,数据关联明确
const [state, setState] = useState({ status: "idle" })
// setState({ status: "success", data: user })
// setState({ status: "error", error: "..." })
把相关的数据放在状态对象里,保证一致性。
五、状态机的两种实现方式
方式一:枚举 + useState
适用于简单状态机。
jsx
function useUpload() {
const [status, setStatus] = useState("idle")
const [progress, setProgress] = useState(0)
const [error, setError] = useState(null)
async function upload(file) {
setStatus("uploading")
setProgress(0)
try {
await api.upload(file, setProgress)
setStatus("success")
} catch (e) {
setError(e.message)
setStatus("error")
}
}
function reset() {
setStatus("idle")
setProgress(0)
setError(null)
}
return { status, progress, error, upload, reset }
}
优点: 简单直接,容易理解。
缺点: 状态转移规则散落在各个函数里,复杂状态机难以维护。
方式二:useReducer
适用于复杂状态机。
jsx
const initialState = {
status: "idle",
progress: 0,
error: null
}
function reducer(state, action) {
switch (action.type) {
case "UPLOAD_START":
return { status: "uploading", progress: 0, error: null }
case "UPLOAD_PROGRESS":
if (state.status !== "uploading") return state
return { ...state, progress: action.progress }
case "UPLOAD_SUCCESS":
if (state.status !== "uploading") return state
return { ...state, status: "success" }
case "UPLOAD_ERROR":
if (state.status !== "uploading") return state
return { ...state, status: "error", error: action.error }
case "RESET":
return initialState
default:
return state
}
}
function useUpload() {
const [state, dispatch] = useReducer(reducer, initialState)
async function upload(file) {
dispatch({ type: "UPLOAD_START" })
try {
await api.upload(file, progress => {
dispatch({ type: "UPLOAD_PROGRESS", progress })
})
dispatch({ type: "UPLOAD_SUCCESS" })
} catch (e) {
dispatch({ type: "UPLOAD_ERROR", error: e.message })
}
}
return { ...state, upload, reset: () => dispatch({ type: "RESET" }) }
}
优点:
- 状态转移集中在一个
reducer里,一目了然。 - 转移规则显式,每个 action 处理明确。
- 可以加守卫条件(
if (state.status !== "uploading") return state)。 - 容易测试(纯函数)。
缺点: 代码量稍多。
两种方式的对比
| 维度 | 枚举 + useState | useReducer |
|---|---|---|
| 适用 | 简单状态机(3-4 个状态) | 复杂状态机(5+ 个状态) |
| 转移规则 | 散落在函数里 | 集中在 reducer |
| 守卫条件 | 手动加 | 在 reducer 里统一处理 |
| 测试 | 需要渲染组件 | 纯函数,直接测试 |
| 代码量 | 少 | 多 |
| 可读性 | 简单时好 | 复杂时好 |
选择标准:
- 状态少、转移简单 → 枚举 +
useState。 - 状态多、转移复杂、需要守卫 →
useReducer。
useReducer 的守卫模式
守卫条件:只有合法的状态才能处理某个 action。
jsx
case "UPLOAD_PROGRESS":
if (state.status !== "uploading") return state // 守卫
return { ...state, progress: action.progress }
这个守卫很重要: 如果不在 uploading 状态,收到 UPLOAD_PROGRESS 就忽略。这防止了非法转移。
没有守卫的后果:
jsx
// ❌ 没有守卫,从任何状态都能改 progress
case "UPLOAD_PROGRESS":
return { ...state, progress: action.progress }
// 如果已经是 success,progress 变化会导致状态不一致
用 reducer 集中管理所有转移
jsx
function reducer(state, action) {
switch (action.type) {
case "UPLOAD_START":
// 只有 idle / error / cancelled 能开始
if (!["idle", "error", "cancelled"].includes(state.status)) {
return state
}
return { status: "uploading", progress: 0, error: null }
case "UPLOAD_SUCCESS":
// 只有 uploading 能成功
if (state.status !== "uploading") return state
return { ...state, status: "success" }
// ...
}
}
每个 action 都有守卫,非法转移被自动忽略。 这是状态机的核心------状态转移规则集中在 reducer 里,一目了然。
用动作创建器提高可读性
jsx
// actions.js
export const uploadStart = () => ({ type: "UPLOAD_START" })
export const uploadProgress = (progress) => ({ type: "UPLOAD_PROGRESS", progress })
export const uploadSuccess = () => ({ type: "UPLOAD_SUCCESS" })
export const uploadError = (error) => ({ type: "UPLOAD_ERROR", error })
// 使用
dispatch(uploadStart())
dispatch(uploadProgress(50))
dispatch(uploadSuccess())
动作创建器把 action 的构造集中在一处,方便修改和类型推断。
六、异步流程的状态机
基础模式:四态请求
text
idle ──fetch──▶ loading ──resolve──▶ success
│
└──reject──▶ error
jsx
function useFetch(url) {
const [state, dispatch] = useReducer(reducer, { status: "idle" })
useEffect(() => {
if (!url) return
const controller = new AbortController()
dispatch({ type: "FETCH_START" })
fetch(url, { signal: controller.signal })
.then(r => r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))
.then(data => dispatch({ type: "FETCH_SUCCESS", data }))
.catch(err => {
if (err.name === "AbortError") return
dispatch({ type: "FETCH_ERROR", error: err.message })
})
return () => controller.abort()
}, [url])
return state
}
function reducer(state, action) {
switch (action.type) {
case "FETCH_START":
return { status: "loading" }
case "FETCH_SUCCESS":
if (state.status !== "loading") return state
return { status: "success", data: action.data }
case "FETCH_ERROR":
if (state.status !== "loading") return state
return { status: "error", error: action.error }
default:
return state
}
}
关键点:
- 守卫:
if (state.status !== "loading") return state,防止过期的响应覆盖新状态。 AbortController: 取消旧请求,避免竞态。AbortError忽略: 主动取消的请求不算错误。
进阶模式:带重试的请求
text
idle ──fetch──▶ loading ──resolve──▶ success
│
├──reject──▶ error ──retry──▶ loading
│
└──cancel──▶ cancelled
jsx
function reducer(state, action) {
switch (action.type) {
case "FETCH_START":
return { status: "loading", retryCount: state.retryCount ?? 0 }
case "FETCH_SUCCESS":
if (state.status !== "loading") return state
return { status: "success", data: action.data }
case "FETCH_ERROR":
if (state.status !== "loading") return state
return {
status: "error",
error: action.error,
retryCount: state.retryCount
}
case "RETRY":
if (state.status !== "error") return state
return { status: "loading", retryCount: state.retryCount + 1 }
case "CANCEL":
if (state.status !== "loading" && state.status !== "error") return state
return { status: "cancelled" }
case "RESET":
return { status: "idle" }
default:
return state
}
}
更进阶:多步骤异步流程
场景: 提交订单,包含创建订单、支付、确认三个步骤。
text
idle
│ createOrder()
▼
creating ──resolve──▶ paying ──resolve──▶ confirming ──resolve──▶ success
│ │ │
└──reject──▶ error ◀──┴─────────────────────┘
│
retry() / cancel()
jsx
function reducer(state, action) {
switch (action.type) {
case "START":
if (state.status !== "idle" && state.status !== "error") return state
return { status: "creating" }
case "ORDER_CREATED":
if (state.status !== "creating") return state
return { status: "paying", orderId: action.orderId }
case "PAYMENT_DONE":
if (state.status !== "paying") return state
return { status: "confirming", orderId: state.orderId }
case "CONFIRMED":
if (state.status !== "confirming") return state
return { status: "success", orderId: state.orderId }
case "ERROR":
if (!["creating", "paying", "confirming"].includes(state.status)) {
return state
}
return { status: "error", error: action.error, step: state.status }
case "RESET":
return { status: "idle" }
default:
return state
}
}
多步骤流程的状态机优势:
- 每一步的状态明确。
- 转移规则清晰,不会跳过步骤。
- 出错时知道在哪一步出错(
step字段)。 - 重试时可以从出错步骤继续。
竞态条件的状态机处理
竞态条件: 多个异步操作,返回顺序不确定,导致显示的数据不是最新的。
jsx
// ❌ 没有防竞态
useEffect(() => {
dispatch({ type: "FETCH_START" })
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => dispatch({ type: "FETCH_SUCCESS", data }))
}, [userId])
// 快速切换 userId,旧请求可能后返回,覆盖新数据
状态机的解决方案:给请求打标签。
jsx
function reducer(state, action) {
switch (action.type) {
case "FETCH_START":
return { status: "loading", requestId: action.requestId }
case "FETCH_SUCCESS":
// 只接受当前请求的响应
if (state.status !== "loading") return state
if (state.requestId !== action.requestId) return state
return { status: "success", data: action.data }
case "FETCH_ERROR":
if (state.status !== "loading") return state
if (state.requestId !== action.requestId) return state
return { status: "error", error: action.error }
default:
return state
}
}
useEffect(() => {
const requestId = Date.now()
dispatch({ type: "FETCH_START", requestId })
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => dispatch({ type: "FETCH_SUCCESS", data, requestId }))
}, [userId])
requestId 保证只有最新的请求才能改变状态。 这是状态机处理竞态的标准模式。
更简洁的方案:用 AbortController。
jsx
useEffect(() => {
const controller = new AbortController()
dispatch({ type: "FETCH_START" })
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(r => r.json())
.then(data => dispatch({ type: "FETCH_SUCCESS", data }))
.catch(err => {
if (err.name === "AbortError") return
dispatch({ type: "FETCH_ERROR", error: err.message })
})
return () => controller.abort()
}, [userId])
旧请求被取消,不会返回,自然不会覆盖新数据。
七、复杂 UI 交互的状态机
场景一:模态框栈
需求: 支持多层模态框,打开新模态框时旧的还在下面,关闭时回到上一层。
text
状态集合:栈(数组),每个元素是一个模态框
转移:
PUSH ──▶ 栈加一个
POP ──▶ 栈减一个
CLEAR ──▶ 清空
jsx
function modalReducer(state, action) {
switch (action.type) {
case "PUSH":
return [...state, action.modal]
case "POP":
return state.slice(0, -1)
case "CLEAR":
return []
default:
return state
}
}
function ModalStack() {
const [stack, dispatch] = useReducer(modalReducer, [])
return (
<>
{stack.map((modal, index) => (
<Modal
key={modal.id}
open={true}
isTop={index === stack.length - 1}
onClose={() => dispatch({ type: "POP" })}
>
{modal.content}
</Modal>
))}
</>
)
}
用栈管理模态框,天然支持多层。 这是一个非典型的状态机------状态是一个栈,但转移规则仍然明确。
场景二:拖拽交互
需求: 拖拽一个卡片,从一列到另一列。
text
状态集合:idle, dragging, dragOver, dropping
转移:
idle ──MOUSE_DOWN──▶ dragging
dragging ──MOUSE_ENTER_COLUMN──▶ dragOver
dragOver ──MOUSE_LEAVE_COLUMN──▶ dragging
dragOver ──MOUSE_UP──▶ dropping
dropping ──DROP_DONE──▶ idle
dragging ──MOUSE_UP──▶ idle(取消)
jsx
function dragReducer(state, action) {
switch (action.type) {
case "MOUSE_DOWN":
if (state.phase !== "idle") return state
return {
phase: "dragging",
cardId: action.cardId,
fromColumnId: action.fromColumnId,
overColumnId: null
}
case "MOUSE_ENTER_COLUMN":
if (state.phase !== "dragging" && state.phase !== "dragOver") return state
return { ...state, phase: "dragOver", overColumnId: action.columnId }
case "MOUSE_LEAVE_COLUMN":
if (state.phase !== "dragOver") return state
return { ...state, phase: "dragging", overColumnId: null }
case "MOUSE_UP":
if (state.phase === "dragOver") {
return { ...state, phase: "dropping" }
}
return { phase: "idle" }
case "DROP_DONE":
if (state.phase !== "dropping") return state
return { phase: "idle" }
default:
return state
}
}
拖拽是一个天然的状态机。 用多个布尔会非常混乱。
场景三:多步骤表单
text
状态集合:editing, validating, submitting, success, error
转移:
editing ──NEXT──▶ validating
validating ──VALID──▶ editing(下一步)
validating ──INVALID──▶ editing(显示错误)
editing ──SUBMIT──▶ submitting
submitting ──resolve──▶ success
submitting ──reject──▶ error
error ──RETRY──▶ submitting
error ──BACK──▶ editing
jsx
function formReducer(state, action) {
switch (action.type) {
case "NEXT_STEP":
if (state.status !== "editing") return state
return { ...state, status: "validating" }
case "VALIDATION_OK":
if (state.status !== "validating") return state
return { ...state, status: "editing", step: state.step + 1 }
case "VALIDATION_FAIL":
if (state.status !== "validating") return state
return { ...state, status: "editing", errors: action.errors }
case "SUBMIT":
if (state.status !== "editing") return state
return { ...state, status: "submitting" }
case "SUBMIT_SUCCESS":
if (state.status !== "submitting") return state
return { ...state, status: "success" }
case "SUBMIT_ERROR":
if (state.status !== "submitting") return state
return { ...state, status: "error", error: action.error }
case "RETRY":
if (state.status !== "error") return state
return { ...state, status: "submitting" }
case "BACK_TO_EDIT":
if (state.status !== "error") return state
return { ...state, status: "editing" }
default:
return state
}
}
场景四:支付流程
这是最典型的复杂状态机场景:
text
idle
│ start()
▼
selecting_payment
│ selectPayment(method)
▼
confirming
│ confirm()
▼
processing ──success──▶ success
│
├──fail──▶ failed ──retry()──▶ processing
│
└──timeout──▶ timeout ──retry()──▶ processing
│
└──cancel()──▶ cancelled
jsx
function paymentReducer(state, action) {
switch (action.type) {
case "START":
if (state.status !== "idle") return state
return { status: "selecting_payment" }
case "SELECT_PAYMENT":
if (state.status !== "selecting_payment") return state
return { status: "confirming", method: action.method }
case "CONFIRM":
if (state.status !== "confirming") return state
return { status: "processing", method: state.method }
case "PAYMENT_SUCCESS":
if (state.status !== "processing") return state
return { status: "success", transactionId: action.transactionId }
case "PAYMENT_FAILED":
if (state.status !== "processing") return state
return { status: "failed", error: action.error, method: state.method }
case "PAYMENT_TIMEOUT":
if (state.status !== "processing") return state
return { status: "timeout", method: state.method }
case "RETRY":
if (state.status !== "failed" && state.status !== "timeout") return state
return { status: "processing", method: state.method }
case "CANCEL":
if (state.status !== "timeout") return state
return { status: "cancelled" }
default:
return state
}
}
支付流程的状态机优势:
- 每一步明确,不能跳过。
- 出错时知道在哪一步。
- 重试逻辑清晰。
- 用户界面根据状态显示不同内容。
八、状态机的可视化
为什么可视化
画出状态转移图,能:
- 和产品经理对齐。 图比代码更直观。
- 发现遗漏。 画出所有状态和转移,容易发现漏掉的分支。
- 验证逻辑。 检查每个状态是否有出口,每个事件是否有处理。
- 沟通设计。 团队讨论状态机时,图是最好的工具。
常用的可视化工具
- XState Visualizer: 在线工具,实时预览状态机。
- Mermaid: 用文本描述状态图,支持 Markdown。
- 手绘: 简单场景,白板上画一画。
用 Mermaid 画状态图
#mermaid-svg-OFv48FSMB5PQEefJ{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OFv48FSMB5PQEefJ .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OFv48FSMB5PQEefJ .error-icon{fill:#552222;}#mermaid-svg-OFv48FSMB5PQEefJ .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OFv48FSMB5PQEefJ .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OFv48FSMB5PQEefJ .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OFv48FSMB5PQEefJ .marker.cross{stroke:#333333;}#mermaid-svg-OFv48FSMB5PQEefJ svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OFv48FSMB5PQEefJ p{margin:0;}#mermaid-svg-OFv48FSMB5PQEefJ defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-OFv48FSMB5PQEefJ g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-OFv48FSMB5PQEefJ g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-OFv48FSMB5PQEefJ g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-OFv48FSMB5PQEefJ g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-OFv48FSMB5PQEefJ g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-OFv48FSMB5PQEefJ .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-OFv48FSMB5PQEefJ .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-OFv48FSMB5PQEefJ .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-OFv48FSMB5PQEefJ .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-OFv48FSMB5PQEefJ .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-OFv48FSMB5PQEefJ .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-OFv48FSMB5PQEefJ .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-OFv48FSMB5PQEefJ .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-OFv48FSMB5PQEefJ .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-OFv48FSMB5PQEefJ .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-OFv48FSMB5PQEefJ .edgeLabel .label text{fill:#333;}#mermaid-svg-OFv48FSMB5PQEefJ .label div .edgeLabel{color:#333;}#mermaid-svg-OFv48FSMB5PQEefJ .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-OFv48FSMB5PQEefJ .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-OFv48FSMB5PQEefJ .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-OFv48FSMB5PQEefJ .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-OFv48FSMB5PQEefJ .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-OFv48FSMB5PQEefJ .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OFv48FSMB5PQEefJ .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OFv48FSMB5PQEefJ #statediagram-barbEnd{fill:#333333;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-OFv48FSMB5PQEefJ .cluster-label,#mermaid-svg-OFv48FSMB5PQEefJ .nodeLabel{color:#131300;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-OFv48FSMB5PQEefJ .note-edge{stroke-dasharray:5;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-note text{fill:black;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram-note .nodeLabel{color:black;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagram .edgeLabel{color:red;}#mermaid-svg-OFv48FSMB5PQEefJ #dependencyStart,#mermaid-svg-OFv48FSMB5PQEefJ #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-OFv48FSMB5PQEefJ .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-OFv48FSMB5PQEefJ :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} upload()
resolve
reject
cancel()
retry()
upload()
reset()
idle
uploading
success
error
cancelled
在 Markdown 里写 Mermaid,文档和代码一起维护。
状态机的检查清单
画完状态图,检查:
- 每个状态都有入口吗? 没有入口的状态是死状态。
- 每个状态都有出口吗? 没有出口的状态会让用户卡住。
- 每个事件都有处理吗? 漏掉事件会导致状态不更新。
- 有没有非法转移? 不该发生的转移,要在 reducer 里加守卫。
- 初始状态是哪个? 明确初始状态。
- 有没有终态? 终态是不再转移的状态(如
success可以重置,但不是必须)。
一个真实的设计流程
text
1. 和产品经理讨论需求
│
2. 在白板上画出状态转移图
│
3. 检查每个状态是否有入口和出口
│
4. 发现遗漏的状态(比如"取消")
│
5. 补充状态和转移规则
│
6. 用 Mermaid 写进文档
│
7. 根据状态图实现 reducer
│
8. 为每个转移写测试
状态机是少数能同时服务于产品、开发和测试的建模工具。
九、常见误区与避坑
误区 1:状态机过度使用
jsx
// ❌ 一个布尔,用状态机是过度设计
const [status, setStatus] = useState("closed")
// "closed" | "open"
一个布尔就是最简单的状态机,不需要显式建模。
误区 2:状态机状态太细
jsx
// ❌ 状态太细,转移复杂
const [status, setStatus] = useState("idle")
// "idle" | "opening" | "open" | "closing" | "closed" | "error"
如果 opening 和 open 的区别对用户无意义,可以合并。
误区 3:状态转移不加守卫
jsx
// ❌ 没有守卫,任何状态都能转
case "SUCCESS":
return { status: "success" }
// ✅ 只有 loading 能转 success
case "SUCCESS":
if (state.status !== "loading") return state
return { status: "success" }
守卫防止非法转移。
误区 4:把数据放在多个状态里
jsx
// ❌ 状态和数据分离,可能不一致
const [status, setStatus] = useState("success")
const [data, setData] = useState(null)
// status === "success" 但 data === null
// ✅ 数据放在状态对象里
const [state, setState] = useState({ status: "success", data: user })
误区 5:状态机的状态是 UI 状态而非业务状态
jsx
// ❌ 状态机描述 UI 而非业务
const [status, setStatus] = useState("showingSpinner")
// "showingSpinner" | "hidingSpinner"
// ✅ 描述业务
const [status, setStatus] = useState("loading")
// "idle" | "loading" | "success" | "error"
状态机描述业务状态,UI 从业务状态派生。
误区 6:忘记处理终态
jsx
// ❌ 成功后再调 upload,状态会变吗?
case "UPLOAD_START":
return { status: "uploading" }
// 从 success 状态也能转 uploading,可能不是预期行为
// ✅ 加守卫
case "UPLOAD_START":
if (!["idle", "error", "cancelled"].includes(state.status)) return state
return { status: "uploading" }
误区 7:状态太多
jsx
// ❌ 15 个状态,转移 50 条规则,难以维护
// 这种情况说明状态机粒度太细,或者有多个状态机混在一起
// ✅ 拆分成多个状态机
const [uploadState, uploadDispatch] = useReducer(uploadReducer, ...)
const [formState, formDispatch] = useReducer(formReducer, ...)
一个状态机只负责一个流程。多个流程拆成多个状态机。
误区 8:用 useEffect 驱动状态转移
jsx
// ❌ 用 useEffect 监听状态变化并转移
useEffect(() => {
if (status === "success") {
setStatus("idle")
}
}, [status])
// ✅ 在事件处理器里显式转移
function handleReset() {
setStatus("idle")
}
状态转移由事件触发,不是由状态变化触发。 用 useEffect 驱动状态转移是反模式。
误区 9:状态机里存派生数据
jsx
// ❌ 派生数据放在状态机里
const state = {
status: "success",
data: users,
total: users.length, // 派生
isEmpty: users.length === 0 // 派生
}
// ✅ 只存业务状态,派生数据现算
const state = { status: "success", data: users }
const total = state.data.length
const isEmpty = state.data.length === 0
误区 10:状态机没有明确的初始状态
jsx
// ❌ 初始状态不明确
const [state, setState] = useState({})
// ✅ 明确初始状态
const [state, setState] = useState({ status: "idle" })
初始状态是状态机的起点,必须明确。
误区 11:reducer 里做副作用
jsx
// ❌ reducer 里发请求
function reducer(state, action) {
switch (action.type) {
case "SUBMIT":
fetch("/api/submit", { method: "POST", body: action.data }) // 副作用
return { status: "submitting" }
}
}
// ✅ 副作用在事件处理器里,reducer 保持纯
function handleSubmit(data) {
dispatch({ type: "SUBMIT" })
fetch("/api/submit", { method: "POST", body: JSON.stringify(data) })
.then(() => dispatch({ type: "SUBMIT_SUCCESS" }))
.catch(e => dispatch({ type: "SUBMIT_ERROR", error: e.message }))
}
reducer 必须是纯函数。副作用在事件处理器或 useEffect 里。
误区 12:状态机没有测试
jsx
// reducer 是纯函数,不测试是浪费
reducer 是纯函数,测试极其简单。每个转移都该有测试。
十、跨框架对照
状态机实现
React:
jsx
const [state, dispatch] = useReducer(reducer, initialState)
Vue 3:
js
import { reactive } from 'vue'
const state = reactive({ status: "idle" })
function dispatch(action) {
switch (action.type) {
case "START":
state.status = "loading"
break
// ...
}
}
SwiftUI:
swift
enum Status {
case idle, loading, success(User), error(String)
}
@State private var status: Status = .idle
Flutter:
dart
sealed class UploadState {}
class Idle extends UploadState {}
class Uploading extends UploadState {}
class Success extends UploadState {}
class Failure extends UploadState { final String error; }
Jetpack Compose:
kotlin
sealed class UiState {
object Idle : UiState()
object Loading : UiState()
data class Success(val data: User) : UiState()
data class Error(val message: String) : UiState()
}
var state by remember { mutableStateOf<UiState>(UiState.Idle) }
状态机库
| 框架 | 库 |
|---|---|
| React | XState, useReducer |
| Vue | XState, Pinia |
| SwiftUI | 无(用 enum) |
| Flutter | StateNotifier, Bloc |
| Compose | 无(用 sealed class) |
XState 是跨框架的状态机库,功能强大但学习曲线陡峭。 简单场景用原生 useReducer 就够了。
核心思维一致
所有框架的状态机思维都一样:
- 定义有限状态集合。
- 定义事件和转移规则。
- 用守卫防止非法转移。
- 状态互斥,不可能同时为多个。
- 数据关联到状态,保证一致性。
十一、练一练:18 道多元化习题
1. 选择题
题目: 什么时候该用状态机?
A. 一个布尔状态
B. 多个互斥的布尔状态
C. 一个计数器
D. 一个字符串
参考答案: B
解读: 多个互斥的布尔状态是状态机的典型场景。一个布尔本身就是最简单的状态机,不需要显式建模。计数器和字符串是独立状态,不需要状态机。
2. 判断题
题目: 状态机一定比多个布尔代码少。
参考答案: 错误。
解读: 状态机不一定代码少。它的价值在于结构清晰、非法状态无法表示。对于简单场景,多个布尔可能代码更少;对于复杂场景,状态机的可维护性远超多个布尔。
3. 填空题
题目: 状态机的三个要素是 、、______。
参考答案: 状态集合、转移规则、事件。
解读: 状态集合是所有可能的取值;转移规则是从哪个状态能到哪个状态;事件是触发转移的动作。三要素缺一不可。
4. 代码阅读题
题目: 下面代码有什么问题?如何改成状态机?
jsx
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
参考答案: 三个布尔,理论上 8 种组合,但只有 4 种合法(idle、loading、success、error)。可能出现"加载中且成功"这种非法组合。
改成状态机:
jsx
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"
解读: 多个布尔表示互斥状态,会导致组合爆炸和非法状态。状态机用一个枚举值,从结构上杜绝非法组合。
5. 找错题
题目: 下面代码有什么问题?
jsx
case "SUCCESS":
return { status: "success" }
参考答案: 没有守卫,任何状态都能转 success。比如从 idle 直接转 success,这不应该发生。
修复:
jsx
case "SUCCESS":
if (state.status !== "loading") return state
return { status: "success" }
解读: 守卫防止非法转移。每个 action 都应该检查当前状态是否允许这个转移。
6. 状态机设计题
题目: 为一个"评论编辑器"设计状态机。要求:支持编辑、预览、提交、取消。
参考答案:
text
状态集合:viewing, editing, previewing, submitting, success, error
转移:
viewing ──EDIT──▶ editing
editing ──PREVIEW──▶ previewing
previewing ──BACK──▶ editing
editing ──SUBMIT──▶ submitting
previewing ──SUBMIT──▶ submitting
submitting ──resolve──▶ success
submitting ──reject──▶ error
error ──RETRY──▶ submitting
error ──BACK──▶ editing
editing ──CANCEL──▶ viewing
previewing ──CANCEL──▶ viewing
success ──RESET──▶ viewing
jsx
function reducer(state, action) {
switch (action.type) {
case "EDIT":
if (state.status !== "viewing") return state
return { status: "editing", content: state.content }
case "PREVIEW":
if (state.status !== "editing") return state
return { status: "previewing", content: state.content }
case "BACK":
if (state.status !== "previewing" && state.status !== "error") return state
return { status: "editing", content: state.content }
case "SUBMIT":
if (!["editing", "previewing"].includes(state.status)) return state
return { ...state, status: "submitting" }
case "SUBMIT_SUCCESS":
if (state.status !== "submitting") return state
return { status: "success" }
case "SUBMIT_ERROR":
if (state.status !== "submitting") return state
return { ...state, status: "error", error: action.error }
case "CANCEL":
if (!["editing", "previewing"].includes(state.status)) return state
return { status: "viewing" }
case "RESET":
if (state.status !== "success") return state
return { status: "viewing" }
default:
return state
}
}
解读: 状态机清晰地定义了编辑器的所有状态和转移。每个转移都有守卫,不可能出现非法状态。
7. useReducer 守卫题
题目: 下面 reducer 缺少什么守卫?
jsx
function reducer(state, action) {
switch (action.type) {
case "START":
return { status: "loading" }
case "PROGRESS":
return { ...state, progress: action.value }
case "SUCCESS":
return { status: "success" }
case "ERROR":
return { status: "error", error: action.error }
default:
return state
}
}
参考答案: 所有 case 都缺少守卫。
修复:
jsx
function reducer(state, action) {
switch (action.type) {
case "START":
if (!["idle", "error"].includes(state.status)) return state
return { status: "loading", progress: 0 }
case "PROGRESS":
if (state.status !== "loading") return state
return { ...state, progress: action.value }
case "SUCCESS":
if (state.status !== "loading") return state
return { status: "success" }
case "ERROR":
if (state.status !== "loading") return state
return { status: "error", error: action.error }
default:
return state
}
}
解读: 每个 action 都应该检查当前状态是否允许这个转移。没有守卫,非法转移会发生,导致状态不一致。
8. 不可能状态原则题
题目: 用 TypeScript 判别联合表示一个请求状态。要求:success 有 data,error 有 error。
参考答案:
ts
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string }
消费时:
tsx
function render(state: State) {
switch (state.status) {
case "idle": return null
case "loading": return <Spinner />
case "success": return <Profile user={state.data} />
case "error": return <Error message={state.error} />
}
}
解读: 判别联合保证:
success必有data。error必有error。- 不可能出现"成功但没数据"。
- TypeScript 强制处理所有 case。
9. 状态机 vs 布尔题
题目: 下面场景该用状态机还是布尔?
a. 一个弹窗是否打开
b. 一个文件上传的完整流程
c. 一个计数器
d. 一个多步骤表单的提交流程
e. 深色模式是否开启
参考答案:
- a. 布尔。只有一个状态。
- b. 状态机。多个互斥状态(idle、uploading、success、error)。
- c. 布尔或数字。简单计数,不需要状态机。
- d. 状态机。多个互斥状态(editing、validating、submitting、success、error)。
- e. 布尔。开或关。
解读: 状态机适用于"多个互斥状态、转移规则复杂"的场景。简单布尔、独立状态不需要状态机。
10. 状态转移图题
题目: 用 Mermaid 画出一个"登录"状态机的图。
参考答案:
#mermaid-svg-WBH6JYx5Lxtc83lN{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-WBH6JYx5Lxtc83lN .error-icon{fill:#552222;}#mermaid-svg-WBH6JYx5Lxtc83lN .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-WBH6JYx5Lxtc83lN .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-WBH6JYx5Lxtc83lN .marker{fill:#333333;stroke:#333333;}#mermaid-svg-WBH6JYx5Lxtc83lN .marker.cross{stroke:#333333;}#mermaid-svg-WBH6JYx5Lxtc83lN svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-WBH6JYx5Lxtc83lN p{margin:0;}#mermaid-svg-WBH6JYx5Lxtc83lN defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-WBH6JYx5Lxtc83lN g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-WBH6JYx5Lxtc83lN g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-WBH6JYx5Lxtc83lN g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-WBH6JYx5Lxtc83lN g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-WBH6JYx5Lxtc83lN g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-WBH6JYx5Lxtc83lN .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-WBH6JYx5Lxtc83lN .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-WBH6JYx5Lxtc83lN .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-WBH6JYx5Lxtc83lN .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-WBH6JYx5Lxtc83lN .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-WBH6JYx5Lxtc83lN .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-WBH6JYx5Lxtc83lN .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-WBH6JYx5Lxtc83lN .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-WBH6JYx5Lxtc83lN .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-WBH6JYx5Lxtc83lN .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-WBH6JYx5Lxtc83lN .edgeLabel .label text{fill:#333;}#mermaid-svg-WBH6JYx5Lxtc83lN .label div .edgeLabel{color:#333;}#mermaid-svg-WBH6JYx5Lxtc83lN .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-WBH6JYx5Lxtc83lN .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-WBH6JYx5Lxtc83lN .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-WBH6JYx5Lxtc83lN .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-WBH6JYx5Lxtc83lN .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-WBH6JYx5Lxtc83lN .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WBH6JYx5Lxtc83lN .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WBH6JYx5Lxtc83lN #statediagram-barbEnd{fill:#333333;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-WBH6JYx5Lxtc83lN .cluster-label,#mermaid-svg-WBH6JYx5Lxtc83lN .nodeLabel{color:#131300;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-WBH6JYx5Lxtc83lN .note-edge{stroke-dasharray:5;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-note text{fill:black;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram-note .nodeLabel{color:black;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagram .edgeLabel{color:red;}#mermaid-svg-WBH6JYx5Lxtc83lN #dependencyStart,#mermaid-svg-WBH6JYx5Lxtc83lN #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-WBH6JYx5Lxtc83lN .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-WBH6JYx5Lxtc83lN :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} submit()
resolve
reject
retry()
back()
idle
submitting
success
error
解读: 状态图是状态机设计的好工具。画出来,和产品经理对齐,发现遗漏的分支。
11. 多步骤流程题
题目: 为一个"三步向导"设计状态机。要求:步骤 0 → 1 → 2,每步可前进后退,最后提交。
参考答案:
jsx
const initialState = {
status: "editing", // "editing" | "submitting" | "success" | "error"
step: 0, // 0 | 1 | 2
data: {},
error: null
}
function reducer(state, action) {
switch (action.type) {
case "NEXT":
if (state.status !== "editing") return state
if (state.step >= 2) return state
return { ...state, step: state.step + 1 }
case "PREV":
if (state.status !== "editing") return state
if (state.step <= 0) return state
return { ...state, step: state.step - 1 }
case "UPDATE_DATA":
if (state.status !== "editing") return state
return {
...state,
data: { ...state.data, [action.field]: action.value }
}
case "SUBMIT":
if (state.status !== "editing" || state.step !== 2) return state
return { ...state, status: "submitting" }
case "SUBMIT_SUCCESS":
if (state.status !== "submitting") return state
return { ...state, status: "success" }
case "SUBMIT_ERROR":
if (state.status !== "submitting") return state
return { ...state, status: "error", error: action.error }
case "RETRY":
if (state.status !== "error") return state
return { ...state, status: "submitting" }
case "BACK_TO_EDIT":
if (state.status !== "error") return state
return { ...state, status: "editing", error: null }
default:
return state
}
}
解读: 多步骤流程用状态机,step 是状态的一部分,status 描述整体流程。每个 action 都有守卫,防止非法转移。
12. 状态机测试题
题目: 如何测试一个 useReducer 的 reducer?
参考答案:
reducer 是纯函数,可以直接测试,不需要渲染组件。
jsx
describe("uploadReducer", () => {
test("idle → uploading", () => {
const state = { status: "idle" }
const next = reducer(state, { type: "START" })
expect(next.status).toBe("uploading")
})
test("uploading → success", () => {
const state = { status: "uploading", progress: 50 }
const next = reducer(state, { type: "SUCCESS" })
expect(next.status).toBe("success")
})
test("非法转移被忽略", () => {
const state = { status: "idle" }
const next = reducer(state, { type: "SUCCESS" })
expect(next).toBe(state) // 状态不变
})
test("守卫防止非法转移", () => {
const state = { status: "success" }
const next = reducer(state, { type: "START" })
expect(next.status).toBe("success") // 不转回 uploading
})
})
解读: reducer 是纯函数,测试极其简单。这是 useReducer 相比多个布尔的一大优势------逻辑可独立测试。
13. 状态机拆分题
题目: 下面状态机有 12 个状态,如何拆分?
jsx
// 一个页面:加载数据、筛选、分页、弹窗、编辑
const [state, setState] = useState({
status: "idle", // 数据加载
filter: "all", // 筛选
page: 1, // 分页
isModalOpen: false, // 弹窗
editingId: null // 编辑
})
参考答案: 拆分成多个独立状态,不要全部塞进一个状态机。
jsx
// 数据加载:状态机
const [fetchStatus, setFetchStatus] = useState("idle")
// 筛选:普通状态
const [filter, setFilter] = useState("all")
// 分页:普通状态
const [page, setPage] = useState(1)
// 弹窗:布尔
const [isModalOpen, setIsModalOpen] = useState(false)
// 编辑:普通状态
const [editingId, setEditingId] = useState(null)
解读: 一个状态机只负责一个流程。加载数据是状态机,筛选是普通状态,分页是普通状态。把它们塞进一个状态机会导致状态爆炸。
14. 状态机可视化题
题目: 用 Mermaid 画出一个"文件上传"的状态机图。
参考答案:
#mermaid-svg-CUzNq1JiVM1d357y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-CUzNq1JiVM1d357y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-CUzNq1JiVM1d357y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-CUzNq1JiVM1d357y .error-icon{fill:#552222;}#mermaid-svg-CUzNq1JiVM1d357y .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-CUzNq1JiVM1d357y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-CUzNq1JiVM1d357y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-CUzNq1JiVM1d357y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-CUzNq1JiVM1d357y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-CUzNq1JiVM1d357y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-CUzNq1JiVM1d357y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-CUzNq1JiVM1d357y .marker{fill:#333333;stroke:#333333;}#mermaid-svg-CUzNq1JiVM1d357y .marker.cross{stroke:#333333;}#mermaid-svg-CUzNq1JiVM1d357y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-CUzNq1JiVM1d357y p{margin:0;}#mermaid-svg-CUzNq1JiVM1d357y defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-CUzNq1JiVM1d357y g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-CUzNq1JiVM1d357y g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-CUzNq1JiVM1d357y g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-CUzNq1JiVM1d357y g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-CUzNq1JiVM1d357y g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-CUzNq1JiVM1d357y .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-CUzNq1JiVM1d357y .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-CUzNq1JiVM1d357y .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-CUzNq1JiVM1d357y .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-CUzNq1JiVM1d357y .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-CUzNq1JiVM1d357y .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-CUzNq1JiVM1d357y .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-CUzNq1JiVM1d357y .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-CUzNq1JiVM1d357y .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-CUzNq1JiVM1d357y .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-CUzNq1JiVM1d357y .edgeLabel .label text{fill:#333;}#mermaid-svg-CUzNq1JiVM1d357y .label div .edgeLabel{color:#333;}#mermaid-svg-CUzNq1JiVM1d357y .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-CUzNq1JiVM1d357y .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-CUzNq1JiVM1d357y .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-CUzNq1JiVM1d357y .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-CUzNq1JiVM1d357y .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-CUzNq1JiVM1d357y .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CUzNq1JiVM1d357y .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CUzNq1JiVM1d357y #statediagram-barbEnd{fill:#333333;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-CUzNq1JiVM1d357y .cluster-label,#mermaid-svg-CUzNq1JiVM1d357y .nodeLabel{color:#131300;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-CUzNq1JiVM1d357y .note-edge{stroke-dasharray:5;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-note text{fill:black;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram-note .nodeLabel{color:black;}#mermaid-svg-CUzNq1JiVM1d357y .statediagram .edgeLabel{color:red;}#mermaid-svg-CUzNq1JiVM1d357y #dependencyStart,#mermaid-svg-CUzNq1JiVM1d357y #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-CUzNq1JiVM1d357y .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-CUzNq1JiVM1d357y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} upload()
progress
resolve
reject
cancel()
retry()
upload()
reset()
idle
uploading
success
error
cancelled
解读: 状态图帮助发现遗漏。比如 uploading 有一个自循环(progress 更新),这是容易忽略的。
15. 状态机与派生题
题目: 状态机里的数据,哪些应该放状态,哪些应该派生?
jsx
const state = {
status: "success",
data: users,
total: users.length,
isEmpty: users.length === 0,
hasNext: page < totalPages
}
参考答案:
status:状态。data:状态。total:派生(data.length)。isEmpty:派生(data.length === 0)。hasNext:派生(page < totalPages)。
修复:
jsx
const state = {
status: "success",
data: users
}
const total = state.data.length
const isEmpty = state.data.length === 0
解读: 状态机只存业务状态,派生数据现算。不要把派生数据塞进状态机。
16. 状态机守卫题
题目: 下面 reducer 有什么问题?
jsx
function reducer(state, action) {
switch (action.type) {
case "TOGGLE":
return { expanded: !state.expanded }
default:
return state
}
}
参考答案: 这个 reducer 没有状态集合的概念,只有 expanded 一个布尔。如果只有这一个状态,用 useState 更简单,不需要 useReducer。
如果确实需要状态机:
jsx
function reducer(state, action) {
switch (action.type) {
case "EXPAND":
if (state.status !== "collapsed") return state
return { status: "expanded" }
case "COLLAPSE":
if (state.status !== "expanded") return state
return { status: "collapsed" }
default:
return state
}
}
解读: useReducer 的价值在于管理复杂的状态转移。如果只有一个布尔,用 useState 更直接。不要为了用 useReducer 而用。
17. 状态机实战题
题目: 为一个"购物车结算"设计状态机。要求:
- 查看购物车
- 选择地址
- 选择支付方式
- 确认订单
- 支付
- 成功/失败
参考答案:
text
状态集合:
viewing_cart, selecting_address, selecting_payment,
confirming, processing, success, failed
转移:
viewing_cart ──CHECKOUT──▶ selecting_address
selecting_address ──ADDRESS_SELECTED──▶ selecting_payment
selecting_payment ──PAYMENT_SELECTED──▶ confirming
confirming ──CONFIRM──▶ processing
processing ──resolve──▶ success
processing ──reject──▶ failed
failed ──RETRY──▶ processing
failed ──BACK──▶ confirming
jsx
function cartReducer(state, action) {
switch (action.type) {
case "CHECKOUT":
if (state.status !== "viewing_cart") return state
return { ...state, status: "selecting_address" }
case "ADDRESS_SELECTED":
if (state.status !== "selecting_address") return state
return { ...state, status: "selecting_payment", address: action.address }
case "PAYMENT_SELECTED":
if (state.status !== "selecting_payment") return state
return { ...state, status: "confirming", payment: action.payment }
case "CONFIRM":
if (state.status !== "confirming") return state
return { ...state, status: "processing" }
case "PAYMENT_SUCCESS":
if (state.status !== "processing") return state
return { ...state, status: "success", orderId: action.orderId }
case "PAYMENT_FAILED":
if (state.status !== "processing") return state
return { ...state, status: "failed", error: action.error }
case "RETRY":
if (state.status !== "failed") return state
return { ...state, status: "processing", error: null }
case "BACK":
if (state.status !== "failed") return state
return { ...state, status: "confirming", error: null }
default:
return state
}
}
解读: 结算流程是典型的多步骤状态机。每一步的状态明确,转移规则清晰。这种场景下,多个布尔会非常混乱,状态机是唯一合理的选择。
18. 综合题
题目: 实现一个"可取消、可重试、带进度"的文件上传状态机,包括 reducer、Hook 和 UI。
参考答案:
jsx
const initialState = {
status: "idle",
progress: 0,
error: null,
file: null,
abortController: null
}
function reducer(state, action) {
switch (action.type) {
case "SELECT_FILE":
if (state.status !== "idle") return state
return { ...state, file: action.file }
case "UPLOAD_START":
if (!["idle", "error", "cancelled"].includes(state.status)) return state
return {
...state,
status: "uploading",
progress: 0,
error: null,
abortController: action.abortController
}
case "UPLOAD_PROGRESS":
if (state.status !== "uploading") return state
return { ...state, progress: action.progress }
case "UPLOAD_SUCCESS":
if (state.status !== "uploading") return state
return { ...state, status: "success", progress: 100, abortController: null }
case "UPLOAD_ERROR":
if (state.status !== "uploading") return state
return { ...state, status: "error", error: action.error, abortController: null }
case "UPLOAD_CANCELLED":
if (state.status !== "uploading") return state
return { ...state, status: "cancelled", abortController: null }
case "RETRY":
if (state.status !== "error" && state.status !== "cancelled") return state
return { ...state, status: "idle", error: null }
case "RESET":
return initialState
default:
return state
}
}
function useFileUpload() {
const [state, dispatch] = useReducer(reducer, initialState)
async function upload() {
if (!state.file) return
const controller = new AbortController()
dispatch({ type: "UPLOAD_START", abortController: controller })
try {
await api.upload(state.file, {
signal: controller.signal,
onProgress: p => dispatch({ type: "UPLOAD_PROGRESS", progress: p })
})
dispatch({ type: "UPLOAD_SUCCESS" })
} catch (e) {
if (e.name === "AbortError") {
dispatch({ type: "UPLOAD_CANCELLED" })
} else {
dispatch({ type: "UPLOAD_ERROR", error: e.message })
}
}
}
function cancel() {
state.abortController?.abort()
}
return { state, dispatch, upload, cancel }
}
function FileUpload() {
const { state, dispatch, upload, cancel } = useFileUpload()
return (
<div>
<input
type="file"
disabled={state.status === "uploading"}
onChange={e => dispatch({ type: "SELECT_FILE", file: e.target.files[0] })}
/>
{state.status === "idle" && state.file && (
<button onClick={upload}>上传</button>
)}
{state.status === "uploading" && (
<>
<ProgressBar value={state.progress} />
<button onClick={cancel}>取消</button>
</>
)}
{state.status === "success" && (
<>
<p>上传成功</p>
<button onClick={() => dispatch({ type: "RESET" })}>继续上传</button>
</>
)}
{state.status === "error" && (
<>
<p>上传失败:{state.error}</p>
<button onClick={() => dispatch({ type: "RETRY" })}>重试</button>
</>
)}
{state.status === "cancelled" && (
<>
<p>已取消</p>
<button onClick={() => dispatch({ type: "RETRY" })}>重新上传</button>
</>
)}
</div>
)
}
解读: 这道题综合了状态机的所有要素:
- 状态集合: idle、uploading、success、error、cancelled。
- 转移规则: 集中在 reducer 里。
- 守卫: 每个 action 都检查当前状态。
- 数据关联:
progress、error、file、abortController都在状态对象里。 - 副作用隔离: 上传逻辑在 Hook 里,reducer 是纯函数。
- UI 派生: 每个状态对应不同的 UI。
十二、本课检查清单
设计状态机时,按顺序问自己:
该不该用状态机:
- 有多个互斥状态吗?有 → 状态机。
- 状态组合爆炸吗?是 → 状态机。
- 转移规则复杂吗?是 → 状态机。
- 只有一个布尔?不需要状态机。
- 状态之间独立?不需要状态机。
状态设计:
- 状态集合有限且明确吗?
- 每个状态有明确的含义吗?
- 初始状态是哪个?
- 有没有终态?
- 状态里有没有派生数据?
转移设计:
- 每个事件都有处理吗?
- 每个转移都有守卫吗?
- 非法转移被忽略了吗?
- 有没有死状态(没有出口)?
实现方式:
- 状态少、转移简单 → 枚举 +
useState。 - 状态多、转移复杂 →
useReducer。 - 需要编译时保证 → TypeScript 判别联合。
可视化:
- 画出状态转移图了吗?
- 和产品经理对齐了吗?
- 发现遗漏的分支了吗?
副作用:
- reducer 是纯函数吗?
- 副作用在事件处理器或
useEffect里吗? - 竞态条件处理了吗?
测试:
- reducer 是纯函数吗?
- 测试了所有合法转移吗?
- 测试了非法转移被忽略吗?
反模式自查:
- 有没有状态机过度使用?
- 有没有状态太细?
- 有没有忘记守卫?
- 有没有数据放在多个状态里?
- 有没有用 useEffect 驱动状态转移?
- 有没有把派生数据放进状态机?
- 有没有在 reducer 里做副作用?
十三、课后作业
基础题
题目 1: 把下面多个布尔改成状态机。
jsx
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
参考答案:
jsx
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"
解读: 三个布尔有 8 种组合,只有 4 种合法。状态机用一个枚举值,从结构上杜绝非法组合。
题目 2: 下面 reducer 缺少什么?
jsx
function reducer(state, action) {
switch (action.type) {
case "START":
return { status: "loading" }
case "SUCCESS":
return { status: "success" }
case "ERROR":
return { status: "error", error: action.error }
default:
return state
}
}
参考答案: 缺少守卫。每个 case 都应该检查当前状态是否允许这个转移。
修复:
jsx
function reducer(state, action) {
switch (action.type) {
case "START":
if (!["idle", "error"].includes(state.status)) return state
return { status: "loading" }
case "SUCCESS":
if (state.status !== "loading") return state
return { status: "success" }
case "ERROR":
if (state.status !== "loading") return state
return { status: "error", error: action.error }
default:
return state
}
}
解读: 守卫防止非法转移。没有守卫,任何状态都能转到任何状态,状态机就没有意义了。
题目 3: 用 TypeScript 判别联合表示一个"登录"状态。
参考答案:
ts
type LoginState =
| { status: "idle" }
| { status: "submitting" }
| { status: "success"; user: User }
| { status: "error"; error: string }
解读: 判别联合保证:
success必有user。error必有error。- 不可能出现"成功但没有用户"。
进阶题
题目 4: 为一个"视频播放器"设计状态机。要求:加载、播放、暂停、结束、错误。
参考答案:
text
状态集合:idle, loading, playing, paused, ended, error
转移:
idle ──LOAD──▶ loading
loading ──READY──▶ paused
loading ──ERROR──▶ error
paused ──PLAY──▶ playing
playing ──PAUSE──▶ paused
playing ──END──▶ ended
playing ──ERROR──▶ error
ended ──REPLAY──▶ playing
error ──RETRY──▶ loading
jsx
function playerReducer(state, action) {
switch (action.type) {
case "LOAD":
if (state.status !== "idle") return state
return { status: "loading" }
case "READY":
if (state.status !== "loading") return state
return { status: "paused" }
case "PLAY":
if (state.status !== "paused" && state.status !== "ended") return state
return { status: "playing" }
case "PAUSE":
if (state.status !== "playing") return state
return { status: "paused" }
case "END":
if (state.status !== "playing") return state
return { status: "ended" }
case "ERROR":
if (state.status !== "loading" && state.status !== "playing") return state
return { status: "error", error: action.error }
case "RETRY":
if (state.status !== "error") return state
return { status: "loading" }
default:
return state
}
}
解读: 播放器是典型的状态机场景。每个状态对应不同的 UI 和控制按钮。
题目 5: 实现一个"可取消的搜索"状态机。要求:输入、搜索中、有结果、无结果、错误、取消。
参考答案:
jsx
const initialState = {
status: "idle",
keyword: "",
results: [],
error: null
}
function searchReducer(state, action) {
switch (action.type) {
case "INPUT":
return { ...state, keyword: action.keyword }
case "SEARCH_START":
if (!["idle", "success", "empty", "error", "cancelled"].includes(state.status)) {
return state
}
return { ...state, status: "searching", error: null }
case "SEARCH_SUCCESS":
if (state.status !== "searching") return state
if (action.results.length === 0) {
return { ...state, status: "empty", results: [] }
}
return { ...state, status: "success", results: action.results }
case "SEARCH_ERROR":
if (state.status !== "searching") return state
return { ...state, status: "error", error: action.error }
case "SEARCH_CANCELLED":
if (state.status !== "searching") return state
return { ...state, status: "cancelled" }
case "CLEAR":
return initialState
default:
return state
}
}
解读: 搜索是典型的异步状态机。searching 有多个出口(成功、失败、取消),每个出口都有守卫。
题目 6: 下面状态机有什么问题?
jsx
const [state, setState] = useState({
status: "idle",
data: null,
total: 0,
isEmpty: true,
hasNext: false
})
参考答案: total、isEmpty、hasNext 是派生数据,不该放在状态机里。
修复:
jsx
const [state, setState] = useState({
status: "idle",
data: null
})
const total = state.data?.length ?? 0
const isEmpty = total === 0
const hasNext = /* 派生逻辑 */
解读: 状态机只存业务状态,派生数据现算。这是第 9 课的思维在状态机里的应用。
思考题
题目 7: 状态机和多个布尔的本质区别是什么?
参考答案:
本质区别:
- 状态数量。 n 个布尔有 2^n 种组合,状态机有 n 种状态。状态机把组合爆炸降到线性。
- 非法状态。 多个布尔可能出现非法组合(如"加载中且成功"),状态机从结构上杜绝。
- 转移规则。 多个布尔的转移散落在各处,状态机的转移集中在 reducer 里。
- 可测试性。 状态机的 reducer 是纯函数,容易测试;多个布尔的逻辑分散,难以测试。
- 可推理。 状态机可以画出状态图,多个布尔无法可视化。
一句话: 多个布尔是"原始状态",状态机是"组织化的状态"。
解读: 状态机的价值不在于代码少,而在于结构清晰、非法状态无法表示。这是声明式 UI 中管理复杂交互的核心工具。
题目 8: 什么时候该用 useReducer,什么时候该用 useState?
参考答案:
用 useState:
- 状态少(1-2 个)。
- 转移简单。
- 不需要守卫。
- 状态之间独立。
用 useReducer:
- 状态多(3+ 个)。
- 转移复杂。
- 需要守卫。
- 状态之间互斥。
- 需要集中管理转移规则。
- 需要独立测试 reducer。
例子:
jsx
// useState:简单
const [isOpen, setIsOpen] = useState(false)
// useReducer:复杂
const [state, dispatch] = useReducer(uploadReducer, initialState)
解读: useReducer 的价值在于集中管理状态转移 。如果状态转移简单,useState 更直接。
题目 9: 为什么状态机的数据要放在状态对象里,而不是分开的多个状态?
参考答案:
放在一起的好处:
- 保证一致性。
success状态必有data,不可能出现"成功但没数据"。 - 转移时一起更新。 从一个状态到另一个状态,相关数据一起更新,不会遗漏。
- TypeScript 判别联合。 数据关联到状态,类型系统能保证完整性。
分开的问题:
jsx
// ❌ 可能不一致
const [status, setStatus] = useState("success")
const [data, setData] = useState(null)
// status === "success" 但 data === null
放在一起:
jsx
// ✅ 一致性保证
const [state, setState] = useState({ status: "success", data: user })
解读: 这是"不可能状态原则"的具体应用。相关的数据放在一起,从结构上保证一致性。
挑战题
题目 10: 为一个"视频会议"应用设计状态机。要求:
- 加入会议
- 音频/视频开关
- 屏幕共享
- 网络状态
- 离开会议
参考答案:
jsx
const initialState = {
status: "idle", // "idle" | "joining" | "joined" | "leaving" | "error"
audio: "on", // "on" | "off" | "muted"
video: "on", // "on" | "off"
screenSharing: false,
network: "good", // "good" | "weak" | "reconnecting" | "lost"
error: null
}
function meetingReducer(state, action) {
switch (action.type) {
case "JOIN":
if (state.status !== "idle") return state
return { ...state, status: "joining" }
case "JOIN_SUCCESS":
if (state.status !== "joining") return state
return { ...state, status: "joined" }
case "JOIN_ERROR":
if (state.status !== "joining") return state
return { ...state, status: "error", error: action.error }
case "TOGGLE_AUDIO":
if (state.status !== "joined") return state
return { ...state, audio: state.audio === "on" ? "off" : "on" }
case "MUTE":
if (state.status !== "joined") return state
return { ...state, audio: "muted" }
case "TOGGLE_VIDEO":
if (state.status !== "joined") return state
return { ...state, video: state.video === "on" ? "off" : "on" }
case "START_SCREEN_SHARE":
if (state.status !== "joined") return state
return { ...state, screenSharing: true }
case "STOP_SCREEN_SHARE":
if (state.status !== "joined") return state
return { ...state, screenSharing: false }
case "NETWORK_CHANGE":
if (state.status !== "joined") return state
return { ...state, network: action.network }
case "LEAVE":
if (state.status !== "joined") return state
return { ...state, status: "leaving" }
case "LEAVE_SUCCESS":
if (state.status !== "leaving") return state
return initialState
default:
return state
}
}
设计分析:
status是主状态机: idle → joining → joined → leaving。audio、video、screenSharing是子状态: 只在joined时有意义。network是独立状态: 可以在joined时独立变化。
注意: 这里 audio、video、network 不是互斥的,所以它们不是主状态机的一部分,而是子状态。一个状态机可以包含多个独立的状态。
解读: 复杂场景往往需要多个状态机或状态机 + 独立状态的组合。主状态机管理流程,子状态管理细节。
十四、本课小结
状态机的三要素
- 状态集合: 所有可能的取值。
- 转移规则: 从哪个状态能到哪个状态。
- 事件: 触发转移的动作。
什么时候用状态机
- 多个布尔互斥。
- 状态组合爆炸。
- 转移规则复杂。
- 需要可视化。
- 需要防止非法状态。
- 需要测试状态转移。
不可能状态原则
让非法状态在结构上无法表示,而不是靠运行时检查。
- 枚举值:运行时保证。
- TypeScript 判别联合:编译时保证。
assertNever:强制完备性。
两种实现方式
| 方式 | 适用 | 特点 |
|---|---|---|
枚举 + useState |
简单状态机 | 简单直接 |
useReducer |
复杂状态机 | 转移集中、可测试 |
守卫
每个 action 都要检查当前状态是否允许这个转移。
jsx
case "SUCCESS":
if (state.status !== "loading") return state
return { status: "success" }
数据放在状态对象里
jsx
// ❌ 可能不一致
const [status, setStatus] = useState("success")
const [data, setData] = useState(null)
// ✅ 一致性保证
const [state, setState] = useState({ status: "success", data: user })
状态机可视化
- 用 Mermaid 画状态图。
- 检查每个状态是否有入口和出口。
- 检查每个事件是否有处理。
- 和产品经理对齐。
复杂场景
- 异步流程: 四态请求、带重试、多步骤、竞态处理。
- 复杂 UI: 模态框栈、拖拽、多步骤表单、支付流程。
- 多个状态机: 主状态机 + 子状态,或拆分成多个状态机。
副作用隔离
- reducer 必须是纯函数。
- 副作用在事件处理器或
useEffect里。 - 竞态条件用
requestId或AbortController处理。
五条铁律
- 状态互斥。 同一时刻只有一个状态。
- 转移有守卫。 非法转移被忽略。
- 数据关联状态。 保证一致性。
- 一个状态机只负责一个流程。
- reducer 保持纯。 副作用隔离。
一句话记住本课
用状态机,让非法状态无法表示。
实用价值回顾
- 非法状态无法表示。 从结构上杜绝 bug。
- 状态转移集中。 reducer 里一目了然。
- 可测试。 reducer 是纯函数。
- 可可视化。 状态图帮助设计和沟通。
- 竞态可处理。
requestId或AbortController保证数据一致。 - 跨框架通用。 所有框架都支持状态机思维。
十五、下一课预告
第 11 课 异步数据流:加载、错误、空状态
本课讲了状态机,下一课讲异步数据流的完整处理。核心内容:
- 异步状态的四态模型:idle / loading / success / error
- 竞态条件的处理:取消旧请求
- 请求去重与缓存
- 错误边界与降级
- 乐观更新
- 数据获取库的选择(TanStack Query、SWR)
状态机解决"状态怎么组织",异步数据流解决"数据怎么加载"。两者结合,才能处理真实的异步场景。