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. 适用场景:所有需要在防抖/节流函数中访问最新状态的场景,如搜索输入、滚动事件处理、窗口大小变化等。

参考资料

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

相关推荐
咖啡の猫2 小时前
TypeScript编译选项
前端·javascript·typescript
想学后端的前端工程师2 小时前
【Vue3响应式原理深度解析:从Proxy到依赖收集】
前端·javascript·vue.js
小徐不会敲代码~2 小时前
Vue3 学习 5
前端·学习·vue
_Kayo_2 小时前
vue3 状态管理器 pinia 用法笔记1
前端·javascript·vue.js
How_doyou_do2 小时前
工程级前端智能体FrontAgent
前端
2501_944446002 小时前
Flutter&OpenHarmony日期时间选择器实现
前端·javascript·flutter
二狗哈2 小时前
Cesium快速入门34:3dTile高级样式设置
前端·javascript·算法·3d·webgl·cesium·地图可视化
JS_GGbond2 小时前
前端实战:让表格Header优雅吸顶的魔法
前端
AlanHou2 小时前
Three.js:Web 最重要的 3D 渲染引擎的技术综述
前端·webgl·three.js