阅读视图

发现新文章,点击刷新页面。

踩坑小记之闭包陷阱

问题背景

在页面中,用户可以通过表格中的开关(Switch)组件快速切换计划的启用/禁用状态。系统的预期行为是:

  • 点击第一行的开关从"开启"切换到"关闭"
  • 立即点击第二行的开关从"关闭"切换到"开启"
  • 预期结果:第一行变关闭,第二行变开启

但实际发生的问题是:两行的开关状态会变成一样,要么都变成开启,要么都变成关闭。

问题现象

这个问题在刷新页面后首次操作时必现,尤其是在以下场景:

  1. 用户快速连续点击多行的状态开关
  2. 点击第一行后,在接口请求还未返回时,立即点击第二行
  3. 两个请求基本同时发出

技术实现(错误版本)

原始代码

// 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
// 最终结果:两个更新都生效

应用场景

这个问题常见于以下场景:

  1. 列表行操作:快速切换表格中多行的状态
  2. 表单快速提交:连续提交多个表单项
  3. 购物车操作:快速添加/删除多个商品
  4. 批量操作:连续执行多个列表项的操作

总结

核心要点

项目 错误方式 正确方式
方式 setAppList(updatedList) setAppList(prev => updatedList(prev))
状态来源 闭包捕获的旧值 React 提供的最新值
快速操作 后面的调用覆盖前面的 链式执行,都生效
适用场景 单次更新 多次连续更新

最佳实践

在 React 中更新状态时,如果新状态依赖于旧状态,始终使用函数式更新:

// ✅ 推荐写法(避免闭包陷阱)
setState(prevState => {
  return {
    ...prevState,
    // 基于 prevState 的更新
  }
})

// ❌ 避免(容易踩坑)
setState({
  ...state,
  // 基于当前 state 的更新
})

参考资源


教训: 在 React 中处理依赖于前一状态的更新时,永远优先考虑使用函数式状态更新,这是避免闭包陷阱最直接有效的方法。

❌