React 防抖函数中的闭包陷阱与解决方案

背景

在 React 开发中,我们经常需要对用户输入进行防抖处理,以减少不必要的 API 请求。例如,在搜索功能中,我们希望用户停止输入 500ms 后再发起搜索请求。

然而,当我们在 useEffect 中使用防抖函数时,如果不注意处理,很容易遇到**闭包陷阱(Stale Closure)**问题,导致防抖函数内部访问的是过时的状态值,而不是最新的值。

问题场景

假设我们有一个搜索组件,需要在用户输入关键词时,延迟 500ms 后获取推荐商品列表:

typescript 复制代码
useEffect(() => {
  const fetchProductList = async () => {
    // 如果没有搜索关键字,不请求
    if (!keyWord || !keyWord.trim()) {
      setProductList([])
      return
    }

    const params: Search.SearchParams = {
      keyword: keyWord.trim(),  // 使用 keyWord
      size: 5,
      page: 1,
    }

    if (vehicleData?.attributeValueId) {
      params.attributeValueId = vehicleData.attributeValueId
    }

    const response = await getPublicSearchFilter(params)
    // ... 处理响应
  }

  // 创建防抖函数
  const debouncedFn = debounce(fetchProductList, 500)
  debouncedFn()

  return () => {
    debouncedFn.cancel()
  }
}, [keyWord, vehicleData?.attributeValueId, vehicleData?.allAttributeValue])

问题表现

当用户快速输入时,比如:

  1. 输入 "tire" → keyWord = "tire"
  2. 继续输入 "tire 17" → keyWord = "tire 17"

期望:防抖函数执行时应该使用最新的 keyWord = "tire 17" 实际:防抖函数可能使用的是旧的 keyWord = "tire"

问题分析:闭包陷阱

什么是闭包?

闭包(Closure)是 JavaScript 中的一个重要概念。当一个函数内部定义了另一个函数,并且内部函数引用了外部函数的变量时,就形成了闭包。内部函数会"记住"创建时能访问到的变量。

javascript 复制代码
function createCounter() {
  let count = 0  // 外部变量

  return function() {
    count++  // 闭包捕获了 count
    console.log(count)
  }
}

const counter = createCounter()
counter()  // 输出: 1
counter()  // 输出: 2

React 中的闭包陷阱

在 React 中,每次组件重新渲染时,函数组件都会重新执行,创建新的作用域。这导致了一个问题:

typescript 复制代码
// 第一次渲染:keyWord = "tire"
useEffect(() => {
  // fetchProductList_1 闭包捕获:keyWord = "tire"
  const fetchProductList_1 = async () => {
    console.log(keyWord)  // "tire"
  }

  // debouncedFn 保存了 fetchProductList_1 的引用
  const debouncedFn = debounce(fetchProductList_1, 500)

}, [keyWord])

// 第二次渲染:keyWord = "tire 17"
useEffect(() => {
  // fetchProductList_2 闭包捕获:keyWord = "tire 17"
  const fetchProductList_2 = async () => {
    console.log(keyWord)  // "tire 17"
  }

  // 但是!如果防抖函数已经存在,不会重新创建
  // debouncedFn 内部仍然引用的是 fetchProductList_1
  // 当防抖执行时,调用的是 fetchProductList_1,打印的是 "tire" ❌

}, [keyWord])

为什么会出现这个问题?

  1. 防抖函数只创建一次 :为了保持防抖效果,我们通常会将防抖函数保存在 useRef 中,只创建一次
  2. 闭包捕获旧值 :第一次创建防抖函数时,fetchProductList 通过闭包捕获了当时的 keyWord 值
  3. 后续更新无效 :即使 keyWord 变化,useEffect 重新执行,但防抖函数已经存在,它内部仍然引用的是旧的 fetchProductList,而旧的 fetchProductList 闭包捕获的是旧值

闭包链的传递

erlang 复制代码
keyWord (第一次渲染的值,比如 "tire")
    ↓
fetchProductList (闭包捕获了 keyWord = "tire")
    ↓
debounce(fetchProductList) (保存了 fetchProductList 的引用)
    ↓
debouncedFetchProductListRef.current (防抖函数只创建一次)

当 keyWord 变成 "tire 17" 时:

  • useEffect 重新执行
  • 创建新的 fetchProductList(捕获新的 keyWord = "tire 17")
  • 但防抖函数已经存在,不会重新创建
  • 防抖函数内部仍然引用旧的 fetchProductList(捕获的是 "tire")

解决方案:使用 useRef 保存最新值

核心思路

使用 useRef 来保存最新的状态值,因为 ref.current 是一个可变的对象属性,不受闭包影响,读取时总是最新值。

实现步骤

1. 创建 ref 保存最新值

typescript 复制代码
// 用于保存最新的 keyWord 和 vehicleData
const keyWordRef = useRef<string>(keyWord)
const vehicleDataRef = useRef(vehicleData)
// 用于保存防抖函数
const debouncedFetchProductListRef = useRef<ReturnType<typeof debounce> | null>(null)

2. 在独立的 useEffect 中更新 ref

typescript 复制代码
// 更新 ref 的值,确保防抖函数总是使用最新的值
useEffect(() => {
  keyWordRef.current = keyWord
  vehicleDataRef.current = vehicleData
}, [keyWord, vehicleData])

3. 在防抖函数中使用 ref 获取最新值

typescript 复制代码
useEffect(() => {
  const fetchProductList = async () => {
    // 使用 ref 获取最新的值,而不是直接使用闭包变量
    const currentKeyWord = keyWordRef.current
    const currentVehicleData = vehicleDataRef.current

    // 如果没有搜索关键字,不请求
    if (!currentKeyWord || !currentKeyWord.trim()) {
      setProductList([])
      return
    }

    const params: Search.SearchParams = {
      keyword: currentKeyWord.trim(),  // 使用 ref 的值
      size: 5,
      page: 1,
    }

    if (currentVehicleData?.attributeValueId) {
      params.attributeValueId = currentVehicleData.attributeValueId
    }

    const response = await getPublicSearchFilter(params)
    // ... 处理响应
  }

  // 创建防抖函数(如果还没有创建)
  if (!debouncedFetchProductListRef.current) {
    debouncedFetchProductListRef.current = debounce(fetchProductList, 500)
  }

  // 调用防抖函数
  debouncedFetchProductListRef.current()

  // 清理函数:组件卸载时取消待执行的防抖调用
  return () => {
    if (debouncedFetchProductListRef.current) {
      debouncedFetchProductListRef.current.cancel()
    }
  }
}, [keyWord, vehicleData?.attributeValueId, vehicleData?.allAttributeValue])

完整代码示例

typescript 复制代码
import { useState, useEffect, useRef } from 'react'
import debounce from '@/utils/my-lodash/debounce'

function SearchList() {
  const [keyWord, setKeyWord] = useState('')
  const [vehicleData, setVehicleData] = useState(null)
  const [productList, setProductList] = useState([])
  const [productLoading, setProductLoading] = useState(false)

  // 用于跟踪当前请求的 ID,确保只处理最新请求的结果
  const requestIdRef = useRef<number>(0)
  // 用于保存防抖函数
  const debouncedFetchProductListRef = useRef<ReturnType<typeof debounce> | null>(null)
  // 用于保存最新的 keyWord 和 vehicleData,供防抖函数使用
  const keyWordRef = useRef<string>(keyWord)
  const vehicleDataRef = useRef(vehicleData)

  // 更新 ref 的值,确保防抖函数总是使用最新的值
  useEffect(() => {
    keyWordRef.current = keyWord
    vehicleDataRef.current = vehicleData
  }, [keyWord, vehicleData])

  // 获取推荐商品列表
  useEffect(() => {
    const fetchProductList = async () => {
      // 使用 ref 获取最新的值
      const currentKeyWord = keyWordRef.current
      const currentVehicleData = vehicleDataRef.current

      // 如果没有搜索关键字,不请求
      if (!currentKeyWord || !currentKeyWord.trim()) {
        setProductList([])
        return
      }

      // 生成新的请求 ID
      const currentRequestId = ++requestIdRef.current
      const currentKeyword = currentKeyWord.trim()

      setProductLoading(true)
      try {
        const params: Search.SearchParams = {
          keyword: currentKeyword,
          size: 5,
          page: 1,
        }

        // 如果有车型信息,添加到参数中
        if (currentVehicleData?.attributeValueId) {
          params.attributeValueId = currentVehicleData.attributeValueId
        }
        if (currentVehicleData?.allAttributeValue) {
          params.allAttributeValue = currentVehicleData.allAttributeValue
        }

        const response = await getPublicSearchFilter(params)

        // 检查是否是最新的请求,如果不是则忽略结果
        if (currentRequestId !== requestIdRef.current) {
          return
        }

        // 再次检查关键词是否仍然匹配(双重保险)
        if (currentKeyword !== keyWordRef.current.trim()) {
          return
        }

        const items = response?.itemList?.data || []
        setProductList(items.slice(0, 5))
      } catch (error) {
        if (currentRequestId !== requestIdRef.current) {
          return
        }
        console.error('获取推荐商品失败:', error)
        setProductList([])
      } finally {
        if (currentRequestId === requestIdRef.current) {
          setProductLoading(false)
        }
      }
    }

    // 创建防抖函数(如果还没有创建)
    if (!debouncedFetchProductListRef.current) {
      debouncedFetchProductListRef.current = debounce(fetchProductList, 500)
    }

    // 调用防抖函数
    debouncedFetchProductListRef.current()

    // 清理函数:组件卸载时取消待执行的防抖调用
    return () => {
      if (debouncedFetchProductListRef.current) {
        debouncedFetchProductListRef.current.cancel()
      }
    }
  }, [keyWord, vehicleData?.attributeValueId, vehicleData?.allAttributeValue])

  return (
    // ... JSX
  )
}

原理解析

为什么 ref 可以解决闭包陷阱?

关键在于理解 ref.current 的特性:

  1. ref 是可变的对象属性 :ref.current 是一个对象的属性,不是闭包变量
  2. 读取时总是最新值 :每次读取 ref.current 时,获取的都是当前最新的值
  3. 不受闭包影响 :即使函数通过闭包捕获了 ref 对象,读取 ref.current 时仍然能获取最新值

对比分析

typescript 复制代码
// ❌ 错误方式:直接使用闭包变量
useEffect(() => {
  const fetchProductList = async () => {
    console.log(keyWord)  // 闭包捕获:keyWord = "tire"(创建时的值)
  }
  const debouncedFn = debounce(fetchProductList, 500)
}, [keyWord])

// ✅ 正确方式:使用 ref
const keyWordRef = useRef(keyWord)

useEffect(() => {
  keyWordRef.current = keyWord  // 更新同一个对象的属性
}, [keyWord])

useEffect(() => {
  const fetchProductList = async () => {
    console.log(keyWordRef.current)  // 读取对象属性,总是最新值
  }
  const debouncedFn = debounce(fetchProductList, 500)
}, [])

执行流程对比

错误方式(直接使用闭包变量):

ini 复制代码
第一次渲染:keyWord = "tire"
  → fetchProductList 闭包捕获 keyWord = "tire"
  → debounce(fetchProductList) 保存引用
  → 防抖函数内部:永远只能访问 "tire"

第二次渲染:keyWord = "tire 17"
  → 创建新的 fetchProductList(捕获 "tire 17")
  → 但防抖函数已存在,不会重新创建
  → 防抖函数仍然调用旧的 fetchProductList(捕获 "tire")❌

正确方式(使用 ref):

ini 复制代码
第一次渲染:keyWord = "tire"
  → keyWordRef.current = "tire"
  → fetchProductList 读取 keyWordRef.current
  → debounce(fetchProductList) 保存引用

第二次渲染:keyWord = "tire 17"
  → keyWordRef.current = "tire 17"(更新同一个对象属性)
  → 防抖函数执行时,读取 keyWordRef.current = "tire 17" ✅

最佳实践

1. 分离 ref 更新逻辑

将 ref 的更新放在独立的 useEffect 中,确保每次状态变化时都能及时更新:

typescript 复制代码
// 更新 ref 的值
useEffect(() => {
  keyWordRef.current = keyWord
  vehicleDataRef.current = vehicleData
}, [keyWord, vehicleData])

2. 防抖函数只创建一次

使用条件判断确保防抖函数只创建一次:

typescript 复制代码
if (!debouncedFetchProductListRef.current) {
  debouncedFetchProductListRef.current = debounce(fetchProductList, 500)
}

3. 在清理函数中取消防抖

组件卸载时取消待执行的防抖调用,避免内存泄漏:

typescript 复制代码
return () => {
  if (debouncedFetchProductListRef.current) {
    debouncedFetchProductListRef.current.cancel()
  }
}

4. 使用请求 ID 防止竞态条件

对于异步请求,使用请求 ID 确保只处理最新请求的结果:

typescript 复制代码
const requestIdRef = useRef<number>(0)

const fetchProductList = async () => {
  const currentRequestId = ++requestIdRef.current
  // ... 发起请求

  // 检查是否是最新的请求
  if (currentRequestId !== requestIdRef.current) {
    return  // 忽略旧请求的结果
  }
  // ... 处理响应
}

常见错误

错误 1:在防抖函数中直接使用状态变量

typescript 复制代码
// ❌ 错误
useEffect(() => {
  const fetchProductList = async () => {
    if (!keyWord || !keyWord.trim()) {  // 闭包捕获旧值
      return
    }
    // ...
  }
  const debouncedFn = debounce(fetchProductList, 500)
}, [keyWord])

错误 2:每次重新创建防抖函数

typescript 复制代码
// ❌ 错误:失去防抖效果
useEffect(() => {
  const fetchProductList = async () => { /* ... */ }
  const debouncedFn = debounce(fetchProductList, 500)
  debouncedFn()
  return () => debouncedFn.cancel()
}, [keyWord])  // 每次 keyWord 变化都重新创建,防抖失效

错误 3:忘记更新 ref

typescript 复制代码
// ❌ 错误:ref 没有更新
const keyWordRef = useRef(keyWord)

useEffect(() => {
  const fetchProductList = async () => {
    const currentKeyWord = keyWordRef.current  // 永远是初始值
    // ...
  }
}, [keyWord])  // 缺少更新 ref 的 useEffect

总结

  1. 问题根源:防抖函数只创建一次,但内部函数通过闭包捕获了创建时的状态值,导致后续状态更新无法反映到防抖函数中。

  2. 解决方案 :使用 useRef 保存最新的状态值,在防抖函数中通过 ref.current 访问最新值,避免闭包陷阱。

  3. 关键要点:

    • 防抖函数只创建一次,保持防抖效果
    • 通过 ref 访问最新状态,避免闭包陷阱
    • 及时更新 ref 的值
    • 正确处理清理逻辑
  4. 适用场景:所有需要在防抖/节流函数中访问最新状态的场景,如搜索输入、滚动事件处理、窗口大小变化等。

参考资料

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

相关推荐
jay神35 分钟前
基于 YOLOv8 的轴承缺陷检测(Web 版)
前端·人工智能·yolo
jay神43 分钟前
基于 YOLOv8 的森林火灾检测系统(Web 版)
前端·yolo
code2cat1 小时前
HTML基础篇之第一张网页:从index.html到浏览器里的任务清单
前端·html
miss2 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan2 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真2 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment2 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan2 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么3 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技3 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue