踩坑小记之闭包陷阱
问题背景
在页面中,用户可以通过表格中的开关(Switch)组件快速切换计划的启用/禁用状态。系统的预期行为是:
- 点击第一行的开关从"开启"切换到"关闭"
- 立即点击第二行的开关从"关闭"切换到"开启"
- 预期结果:第一行变关闭,第二行变开启
但实际发生的问题是:两行的开关状态会变成一样,要么都变成开启,要么都变成关闭。
问题现象
这个问题在刷新页面后首次操作时必现,尤其是在以下场景:
- 用户快速连续点击多行的状态开关
- 点击第一行后,在接口请求还未返回时,立即点击第二行
- 两个请求基本同时发出
技术实现(错误版本)
原始代码
// src/page/UpgradePlan/index.js
const [appList, setAppList] = useState([]) // 表格数据列表
/**
* 处理计划状态变更
* @param {object} record - 当前行数据
* @param {string} newStatus - 新状态值 ('ACTIVE' 或 'INACTIVE')
*/
const handlePlanStatusChange = (record, newStatus) => {
// ❌ 问题代码:基于闭包捕获的 appList
const updatedList = appList.map(item => {
if (item.planId === record.planId) {
return {
...item,
planStatus: newStatus,
}
}
return item
})
setAppList(updatedList)
}
表格状态渲染
// src/page/UpgradePlan/UpgradePlanTable.js
const columns = [
{
title: '计划状态',
width: 100,
dataIndex: 'planStatus',
fixed: 'left',
render: (text, record) => {
return (
<Switch
checked={text === 'ACTIVE' ? true : false}
checkedChildren="开启"
unCheckedChildren="关闭"
loading={loadingPlanIds.has(record.planId)}
onChange={checked => {
handlePlanStatusChange(checked, record, text)
}}
/>
)
},
},
// ... 其他列
]
问题根源分析
1. 闭包陷阱
这是一个经典的 React 状态闭包问题:
// ❌ 每次 handlePlanStatusChange 执行时,appList 都是被闭包捕获的"旧值"
const handlePlanStatusChange = (record, newStatus) => {
const updatedList = appList.map(item => { // appList 来自闭包,可能已过时
if (item.planId === record.planId) {
return { ...item, planStatus: newStatus }
}
return item
})
setAppList(updatedList)
}
2. 执行流程演示
假设初始状态:
appList = [
{ planId: 1, planStatus: 'ACTIVE' }, // 第一行:开启
{ planId: 2, planStatus: 'INACTIVE' } // 第二行:关闭
]
快速点击两行的执行流程:
时刻 T0: 初始 appList = [{id:1, status:'ACTIVE'}, {id:2, status:'INACTIVE'}]
时刻 T1: 用户点击第一行开关 (ACTIVE → INACTIVE)
└─ 调用 handlePlanStatusChange(record1, 'INACTIVE')
└─ 函数内捕获的 appList 仍是 T0 时刻的值
└─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
└─ setAppList(updatedList) // 开始异步更新
时刻 T2: 用户立即点击第二行开关 (INACTIVE → ACTIVE) [此时第一个更新还未完成]
└─ 调用 handlePlanStatusChange(record2, 'ACTIVE')
└─ 函数内捕获的 appList 仍是 T0 时刻的值 ⚠️ 关键问题!
└─ updatedList = [{id:1, status:'ACTIVE'}, {id:2, status:'ACTIVE'}]
└─ setAppList(updatedList) // 这个更新会覆盖 T1 的更新
时刻 T3: React 处理状态更新
└─ 第二个 setAppList 覆盖了第一个
└─ 最终结果:[{id:1, status:'ACTIVE'}, {id:2, status:'ACTIVE'}]
└─ ❌ 第一行的状态变更丢失了!
3. 问题本质
-
闭包捕获过时值:
handlePlanStatusChange函数体内的appList是在函数定义时捕获的,不是执行时的最新值 -
异步状态更新:两个
setAppList调用都会加入 React 的更新队列,但都基于同一时刻的旧状态快照 -
后者覆盖前者:第二个
setAppList执行时,会用包含过时数据的updatedList覆盖第一个的更新
改正措施
解决方案:使用函数式状态更新
// ✅ 改正后的代码
/**
* 处理计划状态变更
* @param {object} record - 当前行数据
* @param {string} newStatus - 新状态值
*/
const handlePlanStatusChange = (record, newStatus) => {
// ✅ 使用函数式更新,prevAppList 始终是最新的状态
setAppList(prevAppList => {
return prevAppList.map(item => {
if (item.planId === record.planId) {
return {
...item,
planStatus: newStatus,
}
}
return item
})
})
}
改正后的执行流程
时刻 T0: 初始 appList = [{id:1, status:'ACTIVE'}, {id:2, status:'INACTIVE'}]
时刻 T1: 用户点击第一行开关 (ACTIVE → INACTIVE)
└─ 调用 setAppList(prevAppList => {...})
└─ prevAppList = T0 时刻的值
└─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
└─ 加入更新队列
时刻 T2: 用户立即点击第二行开关 (INACTIVE → ACTIVE)
└─ 调用 setAppList(prevAppList => {...})
└─ ⚠️ 但此时 prevAppList = T1 更新后的值!
└─ prevAppList = [{id:1, status:'INACTIVE'}, {id:2, status:'INACTIVE'}]
└─ 只更新 id:2,保留 id:1 的状态
└─ updatedList = [{id:1, status:'INACTIVE'}, {id:2, status:'ACTIVE'}]
└─ 加入更新队列
时刻 T3: React 处理状态更新(批处理)
└─ 先执行 T1 的更新
└─ 再执行 T2 的更新(基于 T1 的结果)
└─ 最终结果:[{id:1, status:'INACTIVE'}, {id:2, status:'ACTIVE'}]
└─ ✅ 两行状态都正确!
关键区别对比
❌ 错误方式:直接访问闭包变量
const handlePlanStatusChange = (record, newStatus) => {
// appList 是闭包捕获的,在快速连续调用时是同一时刻的值
const updatedList = appList.map(...)
setAppList(updatedList)
}
问题:
- 多次快速调用时,所有调用都基于同一时刻的
appList - 后面的调用会覆盖前面的结果
- 状态变更会丢失
✅ 正确方式:函数式状态更新
const handlePlanStatusChange = (record, newStatus) => {
// prevAppList 参数由 React 提供,始终是最新的状态
setAppList(prevAppList => {
return prevAppList.map(...)
})
}
优势:
- React 保证每次调用时,
prevAppList都是最新的状态 - 多次快速调用时,每次都基于前一次更新的结果
- 状态更新会正确链式执行
- 充分利用 React 的批处理机制
深入理解
React 状态更新的本质
React 的状态更新机制:
// React 内部维护的更新队列
const updateQueue = []
// 当调用 setAppList(newValue) 时
setAppList(newValue)
// React 会加入队列
updateQueue.push({ type: 'direct', value: newValue })
// 当调用 setAppList(prevValue => newValue) 时
setAppList(prevValue => newValue)
// React 会记录更新函数,并在需要时执行
updateQueue.push({ type: 'function', fn: (prevValue) => newValue })
批处理时:
// ❌ 直接值更新(会被覆盖)
setAppList(value1) // → queue: [{ type: 'direct', value: value1 }]
setAppList(value2) // → queue: [{ type: 'direct', value: value2 }] 覆盖前一个
// 最终结果:只有 value2 生效
// ✅ 函数式更新(会链式执行)
setAppList(prev => newValue1(prev)) // → queue: [fn1]
setAppList(prev => newValue2(prev)) // → queue: [fn1, fn2]
// 执行:fn1(initialState) → state1
// fn2(state1) → state2
// 最终结果:两个更新都生效
应用场景
这个问题常见于以下场景:
- 列表行操作:快速切换表格中多行的状态
- 表单快速提交:连续提交多个表单项
- 购物车操作:快速添加/删除多个商品
- 批量操作:连续执行多个列表项的操作
总结
核心要点
| 项目 | 错误方式 | 正确方式 |
|---|---|---|
| 方式 | setAppList(updatedList) |
setAppList(prev => updatedList(prev)) |
| 状态来源 | 闭包捕获的旧值 | React 提供的最新值 |
| 快速操作 | 后面的调用覆盖前面的 | 链式执行,都生效 |
| 适用场景 | 单次更新 | 多次连续更新 |
最佳实践
在 React 中更新状态时,如果新状态依赖于旧状态,始终使用函数式更新:
// ✅ 推荐写法(避免闭包陷阱)
setState(prevState => {
return {
...prevState,
// 基于 prevState 的更新
}
})
// ❌ 避免(容易踩坑)
setState({
...state,
// 基于当前 state 的更新
})
参考资源
教训: 在 React 中处理依赖于前一状态的更新时,永远优先考虑使用函数式状态更新,这是避免闭包陷阱最直接有效的方法。