【声明式UI开发实用技术学与练】第10课 用状态机建模复杂交互

适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。

核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。

本课产出: 能用状态机替代多个布尔;能设计不会出现非法组合的状态;能用枚举和 useReducer 实现状态机;能画出状态转移图;能处理异步流程、多步骤流程、复杂 UI 交互的状态机。

一句话预览: 派生解决"数据怎么算",状态机解决"状态怎么组织"。当你发现自己在维护 isLoading、isError、isSuccess 三个布尔时,就该用状态机了。


一、本课目标

学完本课,你应该能:

  1. 说清楚状态机的三个要素:状态集合、转移规则、事件。
  2. 识别什么时候该用状态机:多个布尔互斥、状态组合爆炸。
  3. 用"不可能状态原则"设计状态机,让非法状态无法表示。
  4. 用枚举和 useReducer 两种方式实现状态机。
  5. 画出状态转移图,用可视化辅助设计。
  6. 处理异步流程、多步骤流程、复杂 UI 交互的状态机。
  7. 用状态机组织副作用,避免竞态和非法转移。
  8. 识别并修复 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>
  )
}

这段代码能跑,但有严重问题:

  1. 五个布尔,理论上 32 种组合。 但只有 5 种合法(idle、uploading、success、error、cancelled)。其余 27 种是非法状态。
  2. 非法组合会出现。 比如 isUploading && isSuccess 同时为 true(上传成功后忘了清 isUploading),界面会同时显示进度条和成功提示。
  3. 每次操作要手动重置多个状态。 忘记重置一个,就出现非法组合。
  4. 取消逻辑不完整。 cancel 只设了 isCancelled 和 isUploading,没清 isSuccess、isError,可能出现多个状态同时为 true。
  5. 无法推理。 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)是一种数学模型,描述一个系统在有限个状态之间的转移。

三个要素:

  1. 状态集合(States): 所有可能的取值。
  2. 转移规则(Transitions): 从哪个状态能到哪个状态。
  3. 事件(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
  }
}

关键点:

  1. 守卫: if (state.status !== "loading") return state,防止过期的响应覆盖新状态。
  2. AbortController: 取消旧请求,避免竞态。
  3. 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
  }
}

支付流程的状态机优势:

  • 每一步明确,不能跳过。
  • 出错时知道在哪一步。
  • 重试逻辑清晰。
  • 用户界面根据状态显示不同内容。

八、状态机的可视化

为什么可视化

画出状态转移图,能:

  1. 和产品经理对齐。 图比代码更直观。
  2. 发现遗漏。 画出所有状态和转移,容易发现漏掉的分支。
  3. 验证逻辑。 检查每个状态是否有出口,每个事件是否有处理。
  4. 沟通设计。 团队讨论状态机时,图是最好的工具。

常用的可视化工具

  • 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,文档和代码一起维护。

状态机的检查清单

画完状态图,检查:

  1. 每个状态都有入口吗? 没有入口的状态是死状态。
  2. 每个状态都有出口吗? 没有出口的状态会让用户卡住。
  3. 每个事件都有处理吗? 漏掉事件会导致状态不更新。
  4. 有没有非法转移? 不该发生的转移,要在 reducer 里加守卫。
  5. 初始状态是哪个? 明确初始状态。
  6. 有没有终态? 终态是不再转移的状态(如 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 就够了。

核心思维一致

所有框架的状态机思维都一样:

  1. 定义有限状态集合。
  2. 定义事件和转移规则。
  3. 用守卫防止非法转移。
  4. 状态互斥,不可能同时为多个。
  5. 数据关联到状态,保证一致性。

十一、练一练: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>
  )
}

解读: 这道题综合了状态机的所有要素:

  1. 状态集合: idle、uploading、success、error、cancelled。
  2. 转移规则: 集中在 reducer 里。
  3. 守卫: 每个 action 都检查当前状态。
  4. 数据关联: progress、error、file、abortController 都在状态对象里。
  5. 副作用隔离: 上传逻辑在 Hook 里,reducer 是纯函数。
  6. 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: 状态机和多个布尔的本质区别是什么?

参考答案:

本质区别:

  1. 状态数量。 n 个布尔有 2^n 种组合,状态机有 n 种状态。状态机把组合爆炸降到线性。
  2. 非法状态。 多个布尔可能出现非法组合(如"加载中且成功"),状态机从结构上杜绝。
  3. 转移规则。 多个布尔的转移散落在各处,状态机的转移集中在 reducer 里。
  4. 可测试性。 状态机的 reducer 是纯函数,容易测试;多个布尔的逻辑分散,难以测试。
  5. 可推理。 状态机可以画出状态图,多个布尔无法可视化。

一句话: 多个布尔是"原始状态",状态机是"组织化的状态"。

解读: 状态机的价值不在于代码少,而在于结构清晰、非法状态无法表示。这是声明式 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: 为什么状态机的数据要放在状态对象里,而不是分开的多个状态?

参考答案:

放在一起的好处:

  1. 保证一致性。 success 状态必有 data,不可能出现"成功但没数据"。
  2. 转移时一起更新。 从一个状态到另一个状态,相关数据一起更新,不会遗漏。
  3. 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
  }
}

设计分析:

  1. status 是主状态机: idle → joining → joined → leaving。
  2. audio、video、screenSharing 是子状态: 只在 joined 时有意义。
  3. network 是独立状态: 可以在 joined 时独立变化。

注意: 这里 audio、video、network 不是互斥的,所以它们不是主状态机的一部分,而是子状态。一个状态机可以包含多个独立的状态。

解读: 复杂场景往往需要多个状态机或状态机 + 独立状态的组合。主状态机管理流程,子状态管理细节。


十四、本课小结

状态机的三要素

  1. 状态集合: 所有可能的取值。
  2. 转移规则: 从哪个状态能到哪个状态。
  3. 事件: 触发转移的动作。

什么时候用状态机

  • 多个布尔互斥。
  • 状态组合爆炸。
  • 转移规则复杂。
  • 需要可视化。
  • 需要防止非法状态。
  • 需要测试状态转移。

不可能状态原则

让非法状态在结构上无法表示,而不是靠运行时检查。

  • 枚举值:运行时保证。
  • 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 处理。

五条铁律

  1. 状态互斥。 同一时刻只有一个状态。
  2. 转移有守卫。 非法转移被忽略。
  3. 数据关联状态。 保证一致性。
  4. 一个状态机只负责一个流程。
  5. reducer 保持纯。 副作用隔离。

一句话记住本课

用状态机,让非法状态无法表示。

实用价值回顾

  1. 非法状态无法表示。 从结构上杜绝 bug。
  2. 状态转移集中。 reducer 里一目了然。
  3. 可测试。 reducer 是纯函数。
  4. 可可视化。 状态图帮助设计和沟通。
  5. 竞态可处理。 requestId 或 AbortController 保证数据一致。
  6. 跨框架通用。 所有框架都支持状态机思维。

十五、下一课预告

第 11 课 异步数据流:加载、错误、空状态

本课讲了状态机,下一课讲异步数据流的完整处理。核心内容:

  • 异步状态的四态模型:idle / loading / success / error
  • 竞态条件的处理:取消旧请求
  • 请求去重与缓存
  • 错误边界与降级
  • 乐观更新
  • 数据获取库的选择(TanStack Query、SWR)

状态机解决"状态怎么组织",异步数据流解决"数据怎么加载"。两者结合,才能处理真实的异步场景。

相关推荐
传奇开心果编程3 小时前
【声明式UI实用开发技术学与练】第5课 表单处理:受控与非受控
ui·移动开发·开发范式·编程框架·声明式ui
传奇开心果编程5 小时前
【声明式UI开发实用技术学与练】第9课 派生状态与计算
ui·移动开发·ux·编程框架·声明式ui
爱编程的小新☆20 天前
Fake GPS 虚拟定位保姆级使用教学
android·移动开发·gps·虚拟定位
Kevin Coding1 个月前
鸿蒙 emitter/EventHub 没有 Sticky 粘性事件?手写一个轻量级 EmitterManager 解决
前端·华为·前端框架·移动开发·harmonyos
天府云创1 个月前
uni-app X 蒸汽模式发布!性能比肩原生~
前端框架·uni-app·移动开发·原生应用·蒸汽模式·app研发·多端适配
m0_749690232 个月前
【寻迹校园 HarmonyOS NEXT 实战 01】从校园痛点到可上架 MVP:失物招领应用产品设计
人工智能·深度学习·移动开发·harmonyos·arkts·arkui·产品设计
m0_749690232 个月前
【寻迹校园 HarmonyOS NEXT 实战 03】NavPathStack 路由实战:18 个页面如何集中管理
华为·移动开发·harmonyos·arkui·navigation·navpathstack
贾伟康2 个月前
【华夏二十四节气|07】HarmonyOS 6.0.2(22) ArkTS 节气搜索实战:多字段匹配与四态闭环
移动开发·harmonyos·arkts·arkui·本地搜索
贾伟康2 个月前
【万能转换器|18】HarmonyOS ArkTS 权限与隐私实战:让 module.json5、功能说明和拒绝路径一致
移动开发·harmonyos·arkts·权限管理·隐私合规