适用技术栈:React、Vue、SwiftUI、Flutter、Jetpack Compose。以 React 为主要教学语言,思维跨框架通用。
核心理念:不讲空理论,每个知识点都落地到能直接用于工作的实用技能。
本课产出: 拿到任何页面,能在 10 分钟内列出它的最小状态清单;能识别冗余状态、冲突状态、放错位置的状态;能熟练运用不可变更新的全部模式;能用状态机处理互斥状态。
一句话预览: 声明式 UI 的代码质量,80% 取决于状态建得好不好。状态建对了,组件自然清晰;状态建错了,再多技巧也救不回来。
一、本课目标
学完本课,你应该能:
- 用三个判断标准,快速区分"状态"和"非状态"。
- 用五类状态框架,给任何页面的状态分类。
- 用五步法,从需求中提取最小状态集。
- 识别并消除四类问题状态:冗余状态、冲突状态、放错位置的状态、可派生却存了的状态。
- 掌握不可变更新的完整模式:数组增删改查、对象增删改查、嵌套更新。
- 用状态机替代多布尔,从结构上杜绝非法状态。
- 判断一个状态该放在组件树的哪个位置。
二、从一个真实痛点说起
场景:一个商品列表页
需求:展示商品列表,支持搜索、筛选、排序、分页,点击商品加入购物车。
一个开发者这样写:
jsx
function ProductList() {
const [products, setProducts] = useState([])
const [keyword, setKeyword] = useState("")
const [filteredProducts, setFilteredProducts] = useState([])
const [sortedProducts, setSortedProducts] = useState([])
const [displayProducts, setDisplayProducts] = useState([])
const [page, setPage] = useState(1)
const [pageCount, setPageCount] = useState(0)
const [cartCount, setCartCount] = useState(0)
const [cartTotal, setCartTotal] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isEmpty, setIsEmpty] = useState(false)
// ... 大量同步逻辑
}
11 个状态。每个状态都需要手动同步:
- 搜索时,要更新
filteredProducts、displayProducts、pageCount、isEmpty。 - 排序时,要更新
sortedProducts、displayProducts。 - 翻页时,要更新
displayProducts。 - 加购物车时,要更新
cartCount、cartTotal。 - 请求开始时,要设置
isLoading、isError、isEmpty三个状态互相配合。
这段代码的 bug 会多到无法维护。 不是开发者水平不行,而是状态建模从一开始就错了。
问题出在哪
逐条分析:
filteredProducts、sortedProducts、displayProducts都是派生的。 它们能从products、keyword、page算出来,根本不该单独存。cartCount、cartTotal是派生的。 从购物车数据算出来就行。isLoading、isError、isEmpty是互斥的。 它们应该合并成一个状态机。pageCount是派生的。 从total和pageSize算出来。
真正的最小状态可能只有 5 个:products、keyword、page、cart、requestStatus。
这就是本课要教的核心技能:从一堆状态中,找出真正的最小真相。
三、什么是状态
状态的定义
状态是组件需要记住的、会随时间变化的最小事实。
三个关键词,缺一不可:
- 记住: 跨渲染保留。普通变量每次渲染都会重置,状态不会。
- 变化: 固定不变的不算状态。
- 最小事实: 不能再从其他状态推导。
三个判断标准
对每一个候选状态,问三个问题:
- 它会随时间变化吗? 不会 → 不是状态,是常量。
- 它能从其他状态算出来吗? 能 → 不是状态,是派生。
- 它影响渲染结果吗? 不影响 → 可能不需要状态,或者应该放在 ref 里。
三个都"是",它才是状态。
一个反例
jsx
// ❌ 这三个都不是状态
const [fullName, setFullName] = useState("") // 能派生
const [listLength, setListLength] = useState(0) // 能派生
const [API_URL] = useState("https://api.example.com") // 不变,是常量
// ✅ 这三个才是
const [firstName, setFirstName] = useState("") // 输入,会变,不可派生
const [lastName, setLastName] = useState("") // 输入,会变,不可派生
const [items, setItems] = useState([]) // 数据,会变,不可派生
一个容易忽略的例外
有一个例外:如果计算代价极高,可以缓存。但缓存不是状态,是性能优化。
jsx
// ❌ 把重计算的结果当状态,还要手动同步
const [result, setResult] = useState([])
useEffect(() => {
setResult(expensiveCompute(data))
}, [data])
// ✅ 用 useMemo 缓存,本质还是派生
const result = useMemo(() => expensiveCompute(data), [data])
useMemo 返回的仍然是派生值,只是加了缓存。它不是状态,因为它不需要 setX,也不会独立变化。
四、五类状态:给状态分类
状态不是铁板一块。按来源和用途,可以分成五类。分类的目的,是知道每类该放在哪里、该怎么处理。
1. 输入状态
定义: 用户直接产生的数据。
例子:
- 输入框内容:
keyword、email、password - 选择器选中值:
selectedCategory、sortBy - 开关状态:
showCompleted、darkMode - 分页器:
page、pageSize - 拖拽位置:
draggingId、dragOverIndex
特点:
- 用户是唯一来源。
- 变化频繁(每次按键都变)。
- 通常需要防抖或受控组件配合。
放置位置: 使用它的组件,或最近的共同父级。
典型处理:
jsx
const [keyword, setKeyword] = useState("")
const [debouncedKeyword, setDebouncedKeyword] = useState(keyword)
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(keyword), 300)
return () => clearTimeout(timer)
}, [keyword])
2. 异步状态
定义: 从外部(主要是服务端)获取的数据及其请求状态。
例子:
- 请求返回的数据:
products、user、comments - 请求状态:
status(idle / loading / success / error) - 错误信息:
error - 分页信息:
total、hasMore
特点:
- 有生命周期:发起 → 等待 → 成功/失败。
- 有竞态问题:后发的请求可能先返回。
- 通常需要缓存和去重。
放置位置: 页面级或全局,取决于共享范围。
关键原则:请求状态用状态机,不要用多个布尔。
jsx
// ❌ 三个布尔可能同时为 true,也可能同时为 false
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
// ✅ 状态机,互斥且完备
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"
3. UI 状态
定义: 纯界面控制的状态,和业务数据无关。
例子:
- 弹窗开关:
isModalOpen - 展开的行:
expandedId - 选中的 Tab:
activeTab - 悬停的元素:
hoveredId - 侧边栏折叠:
sidebarCollapsed - 正在编辑的项:
editingId
特点:
- 不影响业务逻辑。
- 通常不需要持久化。
- 组件卸载后可以丢弃。
放置位置: 就近放置。弹窗状态放在触发弹窗的组件里,不要提到全局。
一个常见的反模式:
jsx
// ❌ 把展开状态存成数组,每次切换要遍历
const [expandedIds, setExpandedIds] = useState([])
// ✅ 如果同时只能展开一个,用单个 id
const [expandedId, setExpandedId] = useState(null)
// ✅ 如果可以同时展开多个,用 Set
const [expandedIds, setExpandedIds] = useState(new Set())
4. 派生状态
定义: 从其他状态计算出来的数据。它不是状态,是计算。
例子:
filteredList = list.filter(...)totalPrice = items.reduce(...)fullName = firstName + lastNameisEmpty = list.length === 0buttonDisabled = !email || !passwordtotalPages = Math.ceil(total / pageSize)hasNext = page < totalPages
特点:
- 不占状态,不消耗内存。
- 自动保持同步,不可能不一致。
- 可以缓存(
useMemo),但不是必须。
处理原则:能派生就不存。
一个判断技巧: 如果某个值能用一行表达式从其他状态算出来,它就是派生的。
jsx
// 一行能算出来的,都是派生
const fullName = `${firstName} ${lastName}`
const isEmpty = items.length === 0
const totalPages = Math.ceil(total / pageSize)
const canSubmit = email && password && !submitting
5. 全局状态
定义: 跨多个页面或多个无关组件共享的状态。
例子:
- 登录用户信息:
currentUser - 主题:
theme - 语言:
locale - 全局通知:
toasts - 跨页面缓存:
productsCache
特点:
- 改动影响面大。
- 通常需要专门的状态管理方案。
- 容易滥用。
放置原则:不到万不得已,不要全局。
判断标准:问自己"这个状态被几个页面用?"一个页面 → 不要全局。多个页面但逻辑独立 → 考虑各自的页面级状态。多个页面且需要同步 → 全局。
五类状态对照表
| 类型 | 来源 | 变化频率 | 放置位置 | 典型例子 |
|---|---|---|---|---|
| 输入状态 | 用户 | 高 | 就近或提升 | keyword、email |
| 异步状态 | 服务端 | 中 | 页面级或全局 | products、status |
| UI 状态 | 界面 | 中 | 就近 | isModalOpen、activeTab |
| 派生状态 | 计算 | --- | 不存,现算 | filteredList、totalPrice |
| 全局状态 | 跨页面 | 低 | 全局 | currentUser、theme |
五、状态建模五步法
拿到一个页面,按这五步做状态建模。每步都有明确的动作和输出。
第一步:列出所有候选状态
把页面上所有"会变的东西"列出来,不管是不是状态。宁可多列,不要漏。
技巧: 从三个角度找。
- 用户能操作什么?(输入、选择、点击、拖拽)
- 服务端能返回什么?(数据、状态、错误)
- 界面上什么在变?(显示/隐藏、展开/折叠、选中/未选中)
第二步:剔除派生状态
对每一个候选,问"它能从别的状态算出来吗?"能,就删掉,改成计算。
技巧: 问"如果其他状态都定了,这个值是不是唯一确定?"是,就是派生的。
第三步:合并冲突状态
对剩下的,问"有没有几个状态是互斥的?"有,合并成状态机。
技巧: 找那些"最多只有一个为 true"的布尔组。
第四步:给状态分类
按五类状态给每个状态打标签,决定放置位置。
第五步:确定最小真相
最终留下的,就是最小状态集。如果两个状态总是一起变,考虑合并成一个对象。
实战演示:商品列表页
第一步:列出候选
用户操作:
- 搜索词、筛选条件、排序方式、页码、每页条数
服务端返回:
- 商品数据、总条数、加载中、加载成功、加载失败、错误信息
界面控制:
- 选中的商品、展开的商品详情
算出来的(后来会发现是派生):
- 过滤后的商品、排序后的商品、当前页商品、总页数
- 购物车数量、购物车总价
- 是否为空、是否显示分页器、是否有上一页、是否有下一页
第二步:剔除派生
- ❌ 过滤后的商品 → 从
products + keyword + filters派生 - ❌ 排序后的商品 → 从
filtered + sortBy派生 - ❌ 当前页商品 → 从
sorted + page + pageSize派生 - ❌ 总页数 → 从
total + pageSize派生 - ❌ 购物车数量 → 从
cart派生 - ❌ 购物车总价 → 从
cart派生 - ❌ 是否为空 → 从
products + status派生 - ❌ 是否显示分页器 → 从
total + pageSize派生 - ❌ 是否有上一页 → 从
page派生 - ❌ 是否有下一页 → 从
page + totalPages派生
第三步:合并冲突
加载中、加载成功、加载失败、错误信息→ 合并为status+error
第四步:分类
| 状态 | 类型 | 放置位置 |
|---|---|---|
keyword |
输入 | 页面 |
filters |
输入 | 页面 |
sortBy |
输入 | 页面 |
page |
输入 | 页面 |
pageSize |
输入 | 页面 |
products |
异步 | 页面 |
total |
异步 | 页面 |
status |
异步 | 页面 |
error |
异步 | 页面 |
cart |
全局 | 全局 |
selectedIds |
UI | 页面 |
expandedId |
UI | 页面 |
第五步:最小状态集
从 20+ 个候选,精简到 12 个状态。其余全部派生。
jsx
function ProductList() {
// 输入状态
const [keyword, setKeyword] = useState("")
const [filters, setFilters] = useState({})
const [sortBy, setSortBy] = useState("default")
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
// 异步状态
const [products, setProducts] = useState([])
const [total, setTotal] = useState(0)
const [status, setStatus] = useState("idle")
const [error, setError] = useState(null)
// UI 状态
const [selectedIds, setSelectedIds] = useState(new Set())
const [expandedId, setExpandedId] = useState(null)
// 派生:不占状态
const filtered = useMemo(() =>
products.filter(p => matchKeyword(p, keyword) && matchFilters(p, filters)),
[products, keyword, filters]
)
const sorted = useMemo(() =>
sortProducts(filtered, sortBy),
[filtered, sortBy]
)
const totalPages = Math.ceil(total / pageSize)
const isEmpty = status === "success" && products.length === 0
const hasPrev = page > 1
const hasNext = page < totalPages
}
对比:原来 20+ 个状态互相同步,现在 12 个状态,其余自动派生。Bug 数量级下降。
六、状态放置:状态该放在哪
状态建对了,还要放对位置。放错位置会导致 props 层层传递、不必要的重渲染、状态同步困难。
放置原则
状态应该放在使用它的最近公共父级。
- 只有一个组件用 → 放在该组件。
- 兄弟组件用 → 提升到父级。
- 跨页面用 → 全局。
三个信号:状态放错了
信号 1:Props 层层传递。
jsx
// ❌ keyword 从 App 传到 Layout 传到 Page 传到 SearchBox
// 中间组件都不用 keyword,只是转发
function App() {
const [keyword, setKeyword] = useState("")
return <Layout keyword={keyword} setKeyword={setKeyword} />
}
function Layout({ keyword, setKeyword }) {
return <Page keyword={keyword} setKeyword={setKeyword} />
}
function Page({ keyword, setKeyword }) {
return <SearchBox keyword={keyword} setKeyword={setKeyword} />
}
说明状态放太深了,应该提升到最近的真正使用它的父级。
信号 2:状态需要同步。
jsx
// ❌ 两个组件各持有一份相关状态,还要手动同步
function SearchBox() {
const [keyword, setKeyword] = useState("")
// 结果列表拿不到 keyword,需要额外机制同步
}
function ResultList() {
const [keyword, setKeyword] = useState("")
// 两边可能不一致
}
信号 3:组件难以复用。
jsx
// ❌ 通用组件内部持有业务状态,无法在不同场景复用
function UserCard() {
const [isFollowing, setIsFollowing] = useState(false)
// 关注状态是业务状态,不该内置在通用卡片里
}
状态下放:避免不必要的全局
反过来,也有状态放太高的反模式:
jsx
// ❌ 弹窗状态放在全局,其实只有这个页面用
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
return <Page isModalOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
}
// ✅ 弹窗状态就近放在使用它的组件
function Page() {
const [isModalOpen, setIsModalOpen] = useState(false)
return <Modal open={isModalOpen} onClose={() => setIsModalOpen(false)} />
}
判断标准: 问自己"除了这个组件,还有谁需要这个状态?"没有,就别提升。
一个决策流程图
text
这个状态有几个组件用?
│
├─ 1 个 → 放在该组件内部
│
├─ 兄弟组件 → 提升到最近公共父级
│
└─ 跨页面 → 考虑全局
│
└─ 真的跨页面吗?
├─ 是 → 全局
└─ 只是同一页面不同区域 → 提升到页面级,不要全局
状态提升的完整模式
提升状态后,子组件变成受控组件:通过 props 接收值,通过回调通知变化。
jsx
// 父组件:持有状态
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
// 子组件:受控,不持有状态
function SearchBox({ value, onChange }) {
return (
<input
value={value}
onChange={e => onChange(e.target.value)}
/>
)
}
function ResultList({ keyword }) {
// 使用 keyword 渲染
}
关键: 子组件不持有状态,它是"受控"的。这保证了状态只有一个真相来源。
七、不可变更新:完整模式
框架靠引用变化检测状态更新。修改数组和对象时,必须创建新引用。
为什么必须不可变
js
const todos = [{ id: 1, done: false }]
todos[0].done = true // 直接改
// todos 的引用没变,框架对比时认为"没变化",跳过更新
框架的 diff 算法用引用相等做快速判断:prev === next 就认为没变。所以必须创建新引用。
数组操作完整模式
添加:
js
// 末尾添加
setItems(prev => [...prev, newItem])
// 开头添加
setItems(prev => [newItem, ...prev])
// 指定位置插入
setItems(prev => [
...prev.slice(0, index),
newItem,
...prev.slice(index)
])
删除:
js
// 按 id 删除
setItems(prev => prev.filter(item => item.id !== id))
// 删除指定索引
setItems(prev => [
...prev.slice(0, index),
...prev.slice(index + 1)
])
// 删除多个
setItems(prev => prev.filter(item => !idsToRemove.has(item.id)))
修改:
js
// 按 id 修改
setItems(prev => prev.map(item =>
item.id === id ? { ...item, ...changes } : item
))
// 按索引修改
setItems(prev => prev.map((item, i) =>
i === index ? { ...item, ...changes } : item
))
// 批量修改
setItems(prev => prev.map(item =>
selectedIds.has(item.id) ? { ...item, selected: true } : item
))
排序与反转:
js
// ❌ sort 会修改原数组
setItems(prev => prev.sort(compare))
// ✅ 先复制再排序
setItems(prev => [...prev].sort(compare))
// ✅ reverse 同理
setItems(prev => [...prev].reverse())
替换:
js
// 替换整个数组
setItems(newArray)
// 替换指定项
setItems(prev => prev.map(item =>
item.id === id ? newItem : item
))
对象操作完整模式
js
// 修改单个字段
setUser(prev => ({ ...prev, name: "新名字" }))
// 修改多个字段
setUser(prev => ({ ...prev, name: "新名字", age: 30 }))
// 删除字段
setUser(prev => {
const { password, ...rest } = prev
return rest
})
// 嵌套对象
setUser(prev => ({
...prev,
address: { ...prev.address, city: "北京" }
}))
嵌套数组完整模式
js
// 修改嵌套数组中的某个元素
setBoard(prev => ({
...prev,
columns: prev.columns.map(col =>
col.id === columnId
? {
...col,
tasks: col.tasks.map(task =>
task.id === taskId ? { ...task, done: true } : task
)
}
: col
)
}))
一条通用规则
从修改点往上,每一层都要创建新引用。
js
// 修改 board.columns[2].tasks[5].done
// 需要新建:task、tasks、column、columns、board
// 共 5 层
用 Immer 简化
手动创建新引用很繁琐,尤其是深层嵌套。Immer 让你用"可变"的写法,自动生成不可变的新对象:
js
import { produce } from "immer"
setBoard(produce(draft => {
draft.columns[2].tasks[5].done = true
}))
推荐在生产项目中使用 Immer。 它消除样板代码,同时保证不可变。
不可变更新的常见陷阱
陷阱 1:sort、reverse、splice 会修改原数组。
js
// ❌
setItems(prev => prev.sort(compare))
setItems(prev => prev.reverse())
setItems(prev => { prev.splice(0, 1); return prev })
// ✅
setItems(prev => [...prev].sort(compare))
setItems(prev => [...prev].reverse())
setItems(prev => prev.slice(1))
陷阱 2:push、pop、shift、unshift 会修改原数组。
js
// ❌
setItems(prev => { prev.push(item); return prev })
// ✅
setItems(prev => [...prev, item])
陷阱 3:嵌套对象浅拷贝不够。
js
// ❌ 只拷贝了外层,内层还是同一个引用
setUser(prev => ({ ...prev, address: { city: "北京" } }))
// 这会丢失 address 的其他字段
// ✅ 正确
setUser(prev => ({
...prev,
address: { ...prev.address, city: "北京" }
}))
陷阱 4:修改后返回同一个引用。
js
// ❌
setItems(prev => {
prev.push(item)
return prev
})
// ✅
setItems(prev => [...prev, item])
八、状态冲突与状态机
冲突状态的典型表现
jsx
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
三个布尔,理论上 8 种组合,但只有 4 种合法(idle、loading、success、error)。非法组合的出现,是 bug 的温床。
典型 bug:
- 请求开始:
setIsLoading(true),但忘了setIsError(false),于是出现"加载中且有错误"。 - 请求成功:
setIsSuccess(true),但忘了setIsLoading(false),于是出现"加载中且成功"。
状态机:让非法状态无法表示
jsx
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"
一个枚举值,4 种取值,天然互斥。不可能出现"加载中且成功"这种状态。
状态机三要素
- 状态集合: 所有可能的取值。
- 转移规则: 从哪个状态能到哪个状态。
- 事件: 触发转移的动作。
text
idle ──submit──▶ loading ──resolve──▶ success
│
└──reject──▶ error
一旦画出这个图,代码结构就清晰了。 每个事件只负责把状态从一个值改到另一个值。
实战:异步请求的状态机
jsx
function UserProfile({ userId }) {
const [status, setStatus] = useState("idle")
const [user, setUser] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
let cancelled = false
setStatus("loading")
fetchUser(userId)
.then(data => {
if (cancelled) return
setUser(data)
setStatus("success")
})
.catch(err => {
if (cancelled) return
setError(err.message)
setStatus("error")
})
return () => { cancelled = true }
}, [userId])
if (status === "idle") return null
if (status === "loading") return <Spinner />
if (status === "error") return <Error message={error} />
return <Profile user={user} />
}
对比多个布尔的写法: 不可能出现"加载中又显示用户"的 bug,因为状态只有一个。
更严格的状态机:TypeScript 联合类型
用 TypeScript 的判别联合,可以让非法状态在类型层面就无法表示:
ts
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string }
这个类型定义保证:
success状态必有data。error状态必有error。- 不可能出现"成功但没有数据"。
- 不可能出现"错误但没有错误信息"。
这是状态机的最高境界:让非法状态在编译期就无法通过。
什么时候用状态机
信号: 当你发现有两个以上的布尔状态"互斥"时,就该用状态机。
| 场景 | 用状态机? | 状态集合 |
|---|---|---|
| 请求加载 | 是 | idle / loading / success / error |
| 文件上传 | 是 | idle / uploading / success / error |
| 支付流程 | 是 | idle / confirming / paying / success / error |
| 多步骤表单 | 是 | editing / validating / submitting / success / error |
| 深色模式 | 否 | 独立布尔 |
| 多语言 | 否 | 独立枚举 |
不是所有状态都要状态机。 独立的状态(如 darkMode 和 language)保持独立即可。
九、常见误区与避坑
误区 1:把派生数据当状态
jsx
// ❌
const [items, setItems] = useState([])
const [totalPrice, setTotalPrice] = useState(0)
function addItem(item) {
const next = [...items, item]
setItems(next)
setTotalPrice(next.reduce((s, i) => s + i.price, 0)) // 手动同步
}
// ✅
const [items, setItems] = useState([])
const totalPrice = items.reduce((s, i) => s + i.price, 0)
原因: 手动同步容易漏,派生永远一致。
误区 2:用多个布尔表示互斥状态
jsx
// ❌ 可能出现"加载中且有错误"
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
// ✅ 状态机
const [status, setStatus] = useState("idle")
误区 3:状态放太深
jsx
// ❌ 兄弟组件需要同一个状态,但各自持有
function SearchBox() {
const [keyword, setKeyword] = useState("")
// 结果列表拿不到 keyword
}
function ResultList() {
// 无法访问 keyword
}
// ✅ 提升到父级
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
误区 4:状态放太高
jsx
// ❌ 弹窗状态放在全局,只有这个页面用
function App() {
const [isModalOpen, setIsModalOpen] = useState(false)
}
// ✅ 就近
function Page() {
const [isModalOpen, setIsModalOpen] = useState(false)
}
误区 5:直接修改状态
jsx
// ❌
items.push(newItem)
setItems(items)
// ✅
setItems(prev => [...prev, newItem])
误区 6:用索引作为列表 key
jsx
// ❌ 删除或排序后,key 错位,状态混乱
{items.map((item, index) => <Item key={index} />)}
// ✅ 用稳定 id
{items.map(item => <Item key={item.id} />)}
原因: 索引会变,id 不会。用索引作 key,删除中间项后,后面所有项的 key 都变了,框架会认为它们都是新项。
误区 7:用 useEffect 同步派生状态
jsx
// ❌ 典型的反模式
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [canSubmit, setCanSubmit] = useState(false)
useEffect(() => {
setCanSubmit(email && password)
}, [email, password])
// ✅ 直接派生
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const canSubmit = email && password
原因: 用 effect 同步派生状态,会导致多一次渲染、中间态不一致、可能死循环。派生就是派生,不该用状态和 effect 模拟。
误区 8:状态对象字段过多
jsx
// ❌ 一个对象装所有东西,任何字段变化都要更新整个对象
const [form, setForm] = useState({
name: "",
email: "",
phone: "",
address: "",
// ... 20 个字段
})
// ✅ 如果字段独立变化,考虑拆开
const [name, setName] = useState("")
const [email, setEmail] = useState("")
// ✅ 如果字段总是一起更新,保持对象
const [dateRange, setDateRange] = useState({ start: null, end: null })
判断标准: 字段总是一起变 → 用对象。字段独立变 → 拆开或分开更新。
十、跨框架对照
状态声明
React:
jsx
const [count, setCount] = useState(0)
Vue 3:
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
SwiftUI:
swift
@State private var count = 0
Flutter:
dart
int count = 0;
// 修改后调用 setState(() {})
Jetpack Compose:
kotlin
var count by remember { mutableStateOf(0) }
派生状态
React:
jsx
const filtered = items.filter(i => i.active)
// 或缓存版本
const filtered = useMemo(() => items.filter(i => i.active), [items])
Vue 3:
js
import { computed } from 'vue'
const filtered = computed(() => items.value.filter(i => i.active))
SwiftUI:
swift
var filtered: [Item] {
items.filter { $0.active }
}
Flutter:
dart
List<Item> get filtered => items.where((i) => i.active).toList();
Jetpack Compose:
kotlin
val filtered = remember(items) { items.filter { it.active } }
不可变更新
React:
jsx
setItems(prev => [...prev, newItem])
Vue 3:
js
items.value = [...items.value, newItem]
SwiftUI:
swift
items.append(newItem)
Flutter:
dart
setState(() {
items = [...items, newItem];
});
Jetpack Compose:
kotlin
items = items + newItem
注意: 不同框架对"不可变"的要求程度不同。React 最严格,SwiftUI 和 Vue 的响应式系统能处理部分可变操作。但养成不可变更新的习惯,在所有框架里都是好实践。
十一、练一练:15 道多元化习题
1. 选择题
题目: 以下哪个不是状态?
A. 输入框的当前内容
B. 从商品列表算出的总价
C. 弹窗是否打开
D. 从服务端加载的用户信息
参考答案: B
解读: 总价可以从商品列表算出来,是派生数据,不是状态。A、C、D 都是不可派生的事实,是状态。这道题考察的是最核心的判断标准:能不能从其他状态算出来。
2. 判断题
题目: 只要能从其他状态算出来,就不应该单独存为状态。
参考答案: 正确。
解读: 这是最小真相原则。冗余状态需要手动同步,容易不一致。但有一个例外:如果计算代价极高(如复杂的大数据聚合),可以用 useMemo 缓存,但缓存不是状态,是性能优化。区分"状态"和"缓存"的关键是:状态有 setX,缓存没有。
3. 填空题
题目: 状态的五类分别是:、 、、、______。
参考答案: 输入状态、异步状态、UI 状态、派生状态、全局状态。
解读: 分类的目的是知道每类该放在哪里、该怎么处理。输入状态就近或提升,异步状态页面级或全局,UI 状态就近,派生状态不存,全局状态慎用。判断一个状态属于哪一类,看它的来源:用户给的、服务端给的、界面产生的、算出来的、跨页面的。
4. 代码阅读题
题目: 下面代码中有哪些状态是冗余的?请说明理由。
jsx
const [items, setItems] = useState([])
const [count, setCount] = useState(0)
const [isEmpty, setIsEmpty] = useState(true)
const [totalPrice, setTotalPrice] = useState(0)
const [keyword, setKeyword] = useState("")
参考答案:
冗余状态:
count:可由items.length派生isEmpty:可由items.length === 0派生totalPrice:可由items.reduce((s, i) => s + i.price, 0)派生
最小状态:
items(异步状态)keyword(输入状态)
解读: 5 个状态精简到 2 个。冗余状态不但多写代码,还引入不一致的可能。识别信号是"它能不能从别的状态算出来"。这道题的关键是识别:count、isEmpty、totalPrice 都唯一由 items 决定。
5. 找错题
题目: 下面代码有什么问题?如何改正?
jsx
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
参考答案: 三个布尔状态互斥但不完备,可能出现"加载中且成功"或"三者都为 false"的非法组合。改为状态机:
jsx
const [status, setStatus] = useState("idle")
// "idle" | "loading" | "success" | "error"
解读: 多个布尔表示互斥状态,会导致组合爆炸和非法状态。状态机用一个枚举值,从结构上杜绝非法组合。这就是"让非法状态无法表示"原则。判断信号:有没有两个以上的布尔"最多只有一个为 true"?有,就该用状态机。
6. 状态放置题
题目: 一个搜索框组件和一个结果列表组件是兄弟关系,都需要访问 keyword。keyword 应该放在哪里?
参考答案: 放在它们的共同父级。
jsx
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
解读: 状态应该放在使用它的最近公共父级。兄弟组件需要共享状态,就必须提升到父级,然后通过 props 向下传、回调向上传。这是声明式 UI 单向数据流的基础。提升后,子组件变成受控组件,状态只有一个真相来源。
7. 不可变更新题
题目: 用不可变方式实现:把 items 中 id 为 3 的元素的 done 字段改为 true。
参考答案:
js
setItems(prev => prev.map(item =>
item.id === 3 ? { ...item, done: true } : item
))
解读: 不可变更新的关键是:修改点往上每一层都创建新引用。这里改的是数组中的某个对象,所以对象和数组都要新建。map 返回新数组,扩展运算符创建新对象。这是最常用的不可变更新模式,必须练到条件反射。
8. 嵌套更新题
题目: 用不可变方式实现:把 board.columns 中 id 为 col-1 的列的 title 改为 "已完成"。
参考答案:
js
setBoard(prev => ({
...prev,
columns: prev.columns.map(col =>
col.id === "col-1" ? { ...col, title: "已完成" } : col
)
}))
解读: 从修改点往上,需要新建:col、columns、board。这是不可变更新的通用规则。项目复杂时,推荐用 Immer 简化:
js
setBoard(produce(draft => {
draft.columns.find(c => c.id === "col-1").title = "已完成"
}))
Immer 让你用"可变"的写法,自动生成不可变的新对象,消除样板代码。
9. 状态分类题
题目: 给下列状态分类(输入、异步、UI、派生、全局):
currentUserkeywordisModalOpenfilteredListproductsthemepage
参考答案:
currentUser→ 全局状态keyword→ 输入状态isModalOpen→ UI 状态filteredList→ 派生状态products→ 异步状态theme→ 全局状态page→ 输入状态
解读: 分类的关键是看来源:用户给的、服务端给的、界面产生的、算出来的、跨页面的。分清楚才知道每类该放哪里。注意 filteredList 虽然名字像状态,但它是派生的,不该用 useState。
10. 状态机设计题
题目: 为一个"文件上传"功能设计状态机。状态有哪些?转移规则是什么?
参考答案:
状态集合:
idle:初始状态uploading:上传中success:上传成功error:上传失败
转移规则:
text
idle ──upload()──▶ uploading
uploading ──resolve──▶ success
uploading ──reject──▶ error
error ──retry()──▶ uploading
success ──reset()──▶ idle
解读: 状态机设计的关键是:列出所有可能的状态,画出状态之间的转移。一旦画出来,代码结构就清晰了。每个事件只负责把状态从一个值改到另一个值。注意 progress 是独立状态,因为它和 status 不互斥------它只在 uploading 时有意义,但是连续值。
11. 状态提升题
题目: 下面代码有什么问题?如何修改?
jsx
function SearchBox() {
const [keyword, setKeyword] = useState("")
return <input value={keyword} onChange={e => setKeyword(e.target.value)} />
}
function ResultList() {
const [keyword, setKeyword] = useState("")
return <div>搜索:{keyword}</div>
}
参考答案: 两个组件各自持有 keyword,无法同步。应该提升到父级:
jsx
function Page() {
const [keyword, setKeyword] = useState("")
return (
<>
<SearchBox value={keyword} onChange={setKeyword} />
<ResultList keyword={keyword} />
</>
)
}
function SearchBox({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />
}
function ResultList({ keyword }) {
return <div>搜索:{keyword}</div>
}
解读: 兄弟组件需要共享状态时,状态必须提升到共同父级。这样只有一个真相来源,不可能不一致。提升后,子组件变成受控组件,通过 props 接收值、通过回调通知变化。这是声明式 UI 单向数据流的标准模式。
12. 派生 vs 状态题
题目: 下面两种写法,哪种更好?为什么?
jsx
// 写法 A
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [canSubmit, setCanSubmit] = useState(false)
useEffect(() => {
setCanSubmit(email && password)
}, [email, password])
// 写法 B
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const canSubmit = email && password
参考答案: 写法 B 更好。
解读: canSubmit 能从 email 和 password 派生,不该作为状态。写法 A 用 useEffect 同步派生状态,是典型的反模式------多了一次渲染,多了一份需要同步的数据。写法 B 中 canSubmit 永远和 email、password 一致,不可能出错。这是状态建模中最常见的错误之一,必须牢记。
13. 不可变陷阱题
题目: 下面代码有什么问题?
js
setItems(prev => prev.sort((a, b) => a.price - b.price))
参考答案: sort 会原地修改数组,prev 被改变,引用不变,框架可能检测不到变化。正确写法:
js
setItems(prev => [...prev].sort((a, b) => a.price - b.price))
解读: JavaScript 的 sort、reverse、splice、push、pop、shift、unshift 都会修改原数组。不可变更新时,必须先用扩展运算符或 slice 复制,再操作。这是 JavaScript 数组 API 的常见陷阱,必须牢记。
14. 状态同步题
题目: 用户切换筛选条件时,页码应该重置为 1。应该如何实现?
参考答案:
jsx
function handleFilterChange(newFilters) {
setFilters(newFilters)
setPage(1)
}
解读: 这是"状态联动"问题:一个状态变化需要触发另一个状态变化。正确做法是在事件处理器里同时更新两个状态,而不是用 useEffect 监听 filters 然后 setPage(1)。原因是:useEffect 的方案会导致多一次渲染,且逻辑更绕。能用事件直接表达的联动,不要用 effect。 这也是"渲染保持纯"原则的延伸。
15. 综合建模题
题目: 为一个"待办事项"应用建模。功能:添加、删除、切换完成、筛选(全部/未完成/已完成)、清除已完成。列出所有状态,标注类型,并写出派生状态。
参考答案:
状态清单:
| 状态 | 类型 | 说明 |
|---|---|---|
todos |
异步/全局 | 待办数据 |
newText |
输入 | 新建输入框内容 |
filter |
输入 | all / active / completed |
editingId |
UI | 正在编辑的待办 id |
status |
异步 | idle / loading / success / error |
error |
异步 | 错误信息 |
派生状态:
js
const filteredTodos = todos.filter(t => {
if (filter === "active") return !t.completed
if (filter === "completed") return t.completed
return true
})
const activeCount = todos.filter(t => !t.completed).length
const completedCount = todos.length - activeCount
const hasCompleted = completedCount > 0
const allCompleted = todos.length > 0 && activeCount === 0
const canAdd = newText.trim().length > 0
const isEmpty = status === "success" && todos.length === 0
解读: 这道题综合了状态分类、派生状态、状态机。几个关键点:
todos是核心数据,其余大多派生。filter是输入状态,它和todos一起派生出filteredTodos。editingId是 UI 状态,就近放置。status用状态机,避免多个布尔。canAdd、isEmpty、activeCount等全部派生,不占状态。
如果这道题你能独立完成,说明状态建模的核心技能已经掌握。
十二、本课检查清单
建模状态时,按顺序问自己:
提取状态:
- 这个页面有哪些会变的东西?
- 哪些是用户给的?(输入状态)
- 哪些是服务端给的?(异步状态)
- 哪些是界面控制?(UI 状态)
- 哪些是跨页面共享的?(全局状态)
精简状态:
- 这个状态能从别的状态算出来吗?能 → 删掉。
- 有没有两个状态总是一起变?有 → 合并。
- 有没有两个状态互斥?有 → 状态机。
放置状态:
- 这个状态有几个组件用?
- 一个 → 就近
- 兄弟 → 提升到共同父级
- 跨页面 → 全局
更新状态:
- 修改数组/对象时,是否创建了新引用?
- 从修改点往上,每一层是否都创建了新引用?
- 有没有用到会修改原数组的 API?(
sort、reverse、splice、push)
状态机:
- 有没有多个布尔表示互斥状态?
- 状态机的状态集合和转移规则清楚吗?
- 能不能画出状态转移图?
反模式自查:
- 有没有用
useEffect同步派生状态? - 有没有用索引作为列表 key?
- 有没有状态放太深或太高?
十三、课后作业
基础题
题目 1: 下面这个组件有 6 个状态,请精简到最少。
jsx
function Cart() {
const [items, setItems] = useState([])
const [count, setCount] = useState(0)
const [totalPrice, setTotalPrice] = useState(0)
const [isEmpty, setIsEmpty] = useState(true)
const [hasDiscount, setHasDiscount] = useState(false)
const [finalPrice, setFinalPrice] = useState(0)
// ... 各种同步逻辑
}
参考答案:
分析:
count→ 从items.length派生totalPrice→ 从items.reduce((s, i) => s + i.price, 0)派生isEmpty→ 从items.length === 0派生hasDiscount→ 从totalPrice >= 100派生finalPrice→ 从totalPrice和hasDiscount派生
最小状态:
jsx
function Cart() {
const [items, setItems] = useState([])
const count = items.length
const totalPrice = items.reduce((s, i) => s + i.price, 0)
const isEmpty = items.length === 0
const hasDiscount = totalPrice >= 100
const finalPrice = hasDiscount ? totalPrice * 0.9 : totalPrice
}
解读: 6 个状态精简到 1 个。冗余状态不但多写代码,还引入不一致的可能。这道题的关键是识别"哪些能从 items 算出来"。判断标准:如果 items 定了,这个值就唯一确定,那它就是派生的。注意 hasDiscount 和 finalPrice 是链式派生------它们依赖 totalPrice,而 totalPrice 又依赖 items。链式派生是正常的,不需要中间状态。
题目 2: 用不可变方式实现以下操作。
js
const [todos, setTodos] = useState([
{ id: 1, text: "学声明式", done: false },
{ id: 2, text: "写代码", done: true }
])
a. 添加 { id: 3, text: "复习", done: false }
b. 删除 id 为 1 的项
c. 把 id 为 2 的项 done 改为 false
d. 清空所有已完成的项
参考答案:
js
// a. 添加
setTodos(prev => [...prev, { id: 3, text: "复习", done: false }])
// b. 删除
setTodos(prev => prev.filter(t => t.id !== 1))
// c. 修改
setTodos(prev => prev.map(t =>
t.id === 2 ? { ...t, done: false } : t
))
// d. 清空已完成
setTodos(prev => prev.filter(t => !t.done))
解读: 四个操作分别对应不可变更新的四种基本模式:扩展运算符添加、filter 删除、map + 扩展运算符修改、filter 条件删除。熟练这四种模式,能覆盖 90% 的数组操作场景。注意 filter 和 map 都返回新数组,所以引用自动变化。
题目 3: 判断下列状态该放在哪里。
a. 当前登录用户信息(多个页面用)
b. 一个下拉菜单是否打开(只有一个组件用)
c. 搜索框关键词(搜索框和结果列表是兄弟组件)
d. 主题(深色/浅色,全应用用)
e. 表单的临时草稿(只有表单组件用)
参考答案:
a. 全局状态 --- 跨页面共享
b. 组件内部状态 --- 就近放置
c. 提升到共同父级 --- 兄弟组件共享
d. 全局状态 --- 全应用共享
e. 组件内部状态 --- 就近放置
解读: 状态放置的核心原则是"最近公共父级"。判断顺序:先问有谁用,再决定放哪里。跨页面 → 全局;兄弟组件 → 提升到父级;只有一个组件用 → 就近。不要不必要地提升,也不要该提升却不提升。 提升过度会导致 props 层层传递,下放过度会导致状态无法共享。
进阶题
题目 4: 下面是一个文件上传组件的状态。请改写成状态机。
jsx
const [isUploading, setIsUploading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [isError, setIsError] = useState(false)
const [progress, setProgress] = useState(0)
参考答案:
jsx
const [status, setStatus] = useState("idle")
// "idle" | "uploading" | "success" | "error"
const [progress, setProgress] = useState(0)
async function upload(file) {
setStatus("uploading")
setProgress(0)
try {
await api.upload(file, p => setProgress(p))
setStatus("success")
} catch {
setStatus("error")
}
}
function reset() {
setStatus("idle")
setProgress(0)
}
return (
<div>
{status === "idle" && <button onClick={() => upload(file)}>上传</button>}
{status === "uploading" && <p>上传中 {progress}%</p>}
{status === "success" && (
<>
<p>上传成功</p>
<button onClick={reset}>继续上传</button>
</>
)}
{status === "error" && (
<>
<p>上传失败</p>
<button onClick={() => upload(file)}>重试</button>
</>
)}
</div>
)
解读: 三个布尔有 8 种组合,只有 4 种合法。状态机用枚举值替代,从结构上杜绝非法组合。注意 progress 保留为独立状态,因为它和 status 不互斥------它只在 uploading 时有意义,但是连续值,不是枚举。状态机和普通状态可以共存,关键是互斥的用状态机,独立的保持独立。
题目 5: 一个"多步骤表单"有 3 步。请设计状态。要求:能前进、后退、提交,提交中有加载状态,提交失败能显示错误。
参考答案:
状态设计:
jsx
// 当前步骤(输入状态)
const [step, setStep] = useState(0) // 0 | 1 | 2
// 表单数据(输入状态)
const [formData, setFormData] = useState({
// step 0
name: "",
email: "",
// step 1
company: "",
role: "",
// step 2
plan: "free"
})
// 提交状态(异步状态,用状态机)
const [status, setStatus] = useState("editing")
// "editing" | "submitting" | "success" | "error"
const [error, setError] = useState(null)
// 派生
const isFirstStep = step === 0
const isLastStep = step === 2
const canGoNext = validateStep(step, formData)
const canSubmit = isLastStep && validateAll(formData)
const isSubmitting = status === "submitting"
事件:
js
function next() {
if (!canGoNext) return
setStep(s => s + 1)
}
function prev() {
setStep(s => s - 1)
}
async function submit() {
if (!canSubmit) return
setStatus("submitting")
try {
await api.submit(formData)
setStatus("success")
} catch (e) {
setError(e.message)
setStatus("error")
}
}
解读: 这道题综合了五类状态。step 和 formData 是输入状态,status 是异步状态机,error 是异步状态,其余是派生。注意 status 的初始值是 editing 而不是 idle,因为它描述的是表单整体状态,编辑中是最自然的初始态。状态机设计要贴合业务语义。 这道题的关键是:step 和 formData 可以合并,但拆开更清晰------step 是导航状态,formData 是数据状态,职责不同。
题目 6: 下面是一段有问题的代码,请找出所有问题并修复。
jsx
function ProductPage() {
const [products, setProducts] = useState([])
const [filteredProducts, setFilteredProducts] = useState([])
const [keyword, setKeyword] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
const [errorMessage, setErrorMessage] = useState("")
const [count, setCount] = useState(0)
useEffect(() => {
setIsLoading(true)
fetchProducts().then(data => {
setProducts(data)
setFilteredProducts(data)
setCount(data.length)
setIsLoading(false)
}).catch(err => {
setIsError(true)
setErrorMessage(err.message)
setIsLoading(false)
})
}, [])
useEffect(() => {
const filtered = products.filter(p => p.name.includes(keyword))
setFilteredProducts(filtered)
setCount(filtered.length)
}, [keyword, products])
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<p>共 {count} 条</p>
{isLoading && <Spinner />}
{isError && <Error message={errorMessage} />}
{filteredProducts.map(p => <Product key={p.id} {...p} />)}
</div>
)
}
参考答案:
问题清单:
filteredProducts是派生的,不该作为状态。count是派生的(filteredProducts.length),不该作为状态。isLoading、isError、errorMessage是互斥的,应该用状态机。- 用
useEffect同步filteredProducts和count,是反模式,多一次渲染。 - 请求没有处理竞态(虽然这里只请求一次,但模式不对)。
- 请求没有清理函数。
修复后:
jsx
function ProductPage() {
const [products, setProducts] = useState([])
const [keyword, setKeyword] = useState("")
const [status, setStatus] = useState("idle")
const [error, setError] = useState(null)
// 派生:不占状态
const filteredProducts = products.filter(p =>
p.name.includes(keyword)
)
const count = filteredProducts.length
useEffect(() => {
let cancelled = false
setStatus("loading")
fetchProducts()
.then(data => {
if (cancelled) return
setProducts(data)
setStatus("success")
})
.catch(err => {
if (cancelled) return
setError(err.message)
setStatus("error")
})
return () => { cancelled = true }
}, [])
return (
<div>
<input value={keyword} onChange={e => setKeyword(e.target.value)} />
<p>共 {count} 条</p>
{status === "loading" && <Spinner />}
{status === "error" && <Error message={error} />}
{status === "success" && filteredProducts.map(p =>
<Product key={p.id} {...p} />
)}
</div>
)
}
解读: 原代码 7 个状态,修复后 4 个。关键改动:删掉派生状态、合并冲突状态、去掉同步用的 useEffect、加上请求清理。这段代码是典型的"初学者综合症":把所有东西都当状态,用 effect 同步一切。 修复后的版本状态清晰,不可能出现不一致。
思考题
题目 7: 为什么"用 useEffect 同步派生状态"是反模式?
参考答案:
因为派生状态根本不该是状态。用 useEffect 同步它,会导致:
- 多一次渲染。 状态变化 → 渲染 → effect 执行 → 改派生状态 → 再渲染。
- 中间态不一致。 第一次渲染时,派生状态还是旧值,界面可能显示错乱。
- 增加复杂度。 本来一行计算搞定,现在要维护状态 + effect。
- 可能死循环。 如果依赖数组处理不当,effect 改状态又触发 effect。
正确做法: 直接用 const derived = compute(state),不用状态,不用 effect。
解读: 判断信号:当你写 useEffect(() => setX(...), [y]) 时,先问"x 是不是能从 y 算出来?"如果是,就不该用状态 + effect,应该直接派生。这个反模式极其常见,是状态建模的头号错误。记住:effect 是给副作用用的,不是给派生用的。
题目 8: 状态提升和状态下放分别解决什么问题?什么时候用哪个?
参考答案:
状态提升解决"多个组件需要共享状态"的问题。当兄弟组件或跨层级组件需要同一份数据时,把状态提升到它们的最近公共父级。
状态下放解决"状态放太高导致不必要的复杂"的问题。当状态只被一个组件使用时,放在该组件内部,不要提升。
判断标准:
- 有多个组件用 → 提升。
- 只有一个组件用 → 下放(就近)。
- 提升的代价是 props 传递,下放的代价是无法共享。
解读: 两者是一对平衡。提升过度,会导致 props 层层传递(prop drilling);下放过度,会导致状态无法共享。经验法则是:默认就近,需要共享时才提升,且只提升到最近公共父级,不要一步到位提到全局。 这是状态放置的核心原则。
题目 9: 什么是"让非法状态无法表示"?举一个例子。
参考答案:
"让非法状态无法表示"是指:通过数据结构的设计,让不可能的状态组合在类型或结构上就无法表达,而不是靠运行时检查。
例子:
jsx
// ❌ 非法状态可表示
const [isLoading, setIsLoading] = useState(false)
const [isError, setIsError] = useState(false)
// 可以同时为 true,非法
// ✅ 非法状态无法表示
const [status, setStatus] = useState("idle")
// 只能是 4 个值之一,天然互斥
更严格的例子(TypeScript):
ts
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string }
这个类型定义保证:success 状态必有 data,error 状态必有 error。不可能出现"成功但没有数据"。
解读: 这是状态机思维的核心价值。与其在运行时检查"这个组合合法吗",不如在结构上让它不可能。 这会大幅减少 bug,因为非法状态根本无法产生。这是状态建模的最高境界。
挑战题
题目 10: 设计一个"看板(Kanban)"应用的状态模型。功能:多个列,每列多个卡片,卡片可在列间拖拽,支持编辑卡片、添加卡片、删除卡片。列出所有状态,标注类型,写出派生状态,并说明拖拽过程中的状态设计。
参考答案:
状态清单:
| 状态 | 类型 | 说明 |
|---|---|---|
board |
异步 | 看板数据:{ columns: [{ id, title, cards: [] }] } |
status |
异步 | idle / loading / success / error |
error |
异步 | 错误信息 |
draggingCardId |
UI | 正在拖拽的卡片 id |
dragOverColumnId |
UI | 当前悬停的列 id |
editingCardId |
UI | 正在编辑的卡片 id |
addingToColumnId |
UI | 正在添加卡片的列 id |
keyword |
输入 | 搜索关键词(可选) |
派生状态:
js
const allCards = board.columns.flatMap(c => c.cards)
const cardCount = allCards.length
const filteredColumns = keyword
? board.columns.map(col => ({
...col,
cards: col.cards.filter(c => c.title.includes(keyword))
}))
: board.columns
const isDragging = draggingCardId !== null
const canDrop = dragOverColumnId !== null
拖拽过程的状态设计:
拖拽有三种常见实现方式:
- HTML5 Drag API:
draggingCardId+dragOverColumnId。 - 鼠标事件: 额外需要
dragOffset(拖拽位置偏移)。 - 第三方库(如 dnd-kit): 库内部管理,你只处理
onDragEnd事件。
拖拽结束时的不可变更新:
js
function handleDragEnd({ cardId, fromColumnId, toColumnId, toIndex }) {
setBoard(prev => {
const fromCol = prev.columns.find(c => c.id === fromColumnId)
const card = fromCol.cards.find(c => c.id === cardId)
return {
...prev,
columns: prev.columns.map(col => {
// 从原列移除
if (col.id === fromColumnId) {
return { ...col, cards: col.cards.filter(c => c.id !== cardId) }
}
// 插入目标列
if (col.id === toColumnId) {
const newCards = [...col.cards]
newCards.splice(toIndex, 0, card)
return { ...col, cards: newCards }
}
return col
})
}
})
}
解读:
这道题是状态建模的综合挑战。几个关键点:
-
board是唯一真相。 所有卡片、列都从它派生。不要在多个状态里分散存储卡片。 -
UI 状态和业务状态分离。
draggingCardId、dragOverColumnId是纯界面控制,不该混入board。 -
拖拽过程本身是 UI 状态。 拖拽中的卡片位置、悬停的列,都是临时的 UI 状态,拖拽结束就清除。
-
嵌套不可变更新。 移动卡片涉及"从原列删除 + 插入目标列",两层嵌套。从修改点往上都要新建引用。复杂场景推荐 Immer。
-
派生过滤。 搜索关键词只影响展示,不改
board本身,所以用派生的filteredColumns。
如果这道题你能独立设计,说明状态建模已经达到生产级水平。
十四、本课小结
状态的定义
状态是组件需要记住的、会随时间变化的最小事实。
三个判断标准:会变、不可派生、影响渲染。
五类状态
| 类型 | 来源 | 放置位置 |
|---|---|---|
| 输入状态 | 用户 | 就近或提升 |
| 异步状态 | 服务端 | 页面级或全局 |
| UI 状态 | 界面 | 就近 |
| 派生状态 | 计算 | 不存,现算 |
| 全局状态 | 跨页面 | 全局 |
状态建模五步法
- 列出候选: 所有会变的东西。
- 剔除派生: 能算出来的删掉。
- 合并冲突: 互斥的用状态机。
- 分类放置: 按五类决定位置。
- 确定最小真相: 最终留下的就是状态。
三条铁律
- 能派生就不存。 减少状态数量,减少同步负担。
- 互斥状态用状态机。 让非法状态无法表示。
- 不可变更新。 从修改点往上,每层都创建新引用。
状态放置原则
状态应该放在使用它的最近公共父级。 就近优先,需要共享才提升,跨页面才全局。
一句话记住本课
状态越少,bug 越少;状态越准,代码越清。
实用价值回顾
- 从 20 个状态精简到 10 个。 代码量减半,bug 数量级下降。
- 派生数据自动同步。 不可能出现"数量和总价对不上"。
- 状态机杜绝非法组合。 不可能出现"加载中且成功"。
- 不可变更新让框架高效检测变化。 性能稳定。
- 状态放置原则避免 props 层层传递。 组件结构清晰。
十五、下一课预告
第 3 课 单向数据流:事件如何改变状态
本课讲了"状态是什么、放在哪",下一课讲"状态怎么流动"。核心内容:
- Props 向下、回调向上的模式
- 受控组件:
value+onChange - 避免双向绑定带来的隐式同步
- 事件处理器的职责边界
- 父子组件通信的完整模式
- 跨层级通信:Context 与组合
状态建模是地基,单向数据流是骨架。两者结合,才能撑起完整的声明式应用。