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

159 阅读8分钟

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

背景

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

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

问题场景

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

useEffect(() => {
  const fetchSearchResults = async () => {
    // 如果没有搜索关键字,不请求
    if (!searchKeyword || !searchKeyword.trim()) {
      setResults([])
      return
    }

    const params = {
      keyword: searchKeyword.trim(),  // 使用 searchKeyword
      pageSize: 10,
      page: 1,
    }

    if (filterOptions?.categoryId) {
      params.categoryId = filterOptions.categoryId
    }

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

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

  return () => {
    debouncedFn.cancel()
  }
}, [searchKeyword, filterOptions?.categoryId, filterOptions?.tags])

问题表现

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

  1. 输入 "react" → searchKeyword = "react"
  2. 继续输入 "react hooks" → searchKeyword = "react hooks"

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

问题分析:闭包陷阱

什么是闭包?

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

function createCounter() {
  let count = 0  // 外部变量

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

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

React 中的闭包陷阱

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

// 第一次渲染:searchKeyword = "react"
useEffect(() => {
  // fetchSearchResults_1 闭包捕获:searchKeyword = "react"
  const fetchSearchResults_1 = async () => {
    console.log(searchKeyword)  // "react"
  }

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

}, [searchKeyword])

// 第二次渲染:searchKeyword = "react hooks"
useEffect(() => {
  // fetchSearchResults_2 闭包捕获:searchKeyword = "react hooks"
  const fetchSearchResults_2 = async () => {
    console.log(searchKeyword)  // "react hooks"
  }

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

}, [searchKeyword])

为什么会出现这个问题?

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

闭包链的传递

searchKeyword (第一次渲染的值,比如 "react")fetchSearchResults (闭包捕获了 searchKeyword = "react")debounce(fetchSearchResults) (保存了 fetchSearchResults 的引用)debouncedFetchRef.current (防抖函数只创建一次)

searchKeyword 变成 "react hooks" 时:

  • useEffect 重新执行
  • 创建新的 fetchSearchResults(捕获新的 searchKeyword = "react hooks"
  • 但防抖函数已经存在,不会重新创建
  • 防抖函数内部仍然引用旧的 fetchSearchResults(捕获的是 "react"

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

核心思路

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

实现步骤

1. 创建 ref 保存最新值
// 用于保存最新的 searchKeyword 和 filterOptions
const searchKeywordRef = useRef<string>(searchKeyword)
const filterOptionsRef = useRef(filterOptions)
// 用于保存防抖函数
const debouncedFetchRef = useRef<ReturnType<typeof debounce> | null>(null)
2. 在独立的 useEffect 中更新 ref
// 更新 ref 的值,确保防抖函数总是使用最新的值
useEffect(() => {
  searchKeywordRef.current = searchKeyword
  filterOptionsRef.current = filterOptions
}, [searchKeyword, filterOptions])
3. 在防抖函数中使用 ref 获取最新值
useEffect(() => {
  const fetchSearchResults = async () => {
    // 使用 ref 获取最新的值,而不是直接使用闭包变量
    const currentKeyword = searchKeywordRef.current
    const currentFilters = filterOptionsRef.current

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

    const params = {
      keyword: currentKeyword.trim(),  // 使用 ref 的值
      pageSize: 10,
      page: 1,
    }

    if (currentFilters?.categoryId) {
      params.categoryId = currentFilters.categoryId
    }

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

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

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

  // 清理函数:组件卸载时取消待执行的防抖调用
  return () => {
    if (debouncedFetchRef.current) {
      debouncedFetchRef.current.cancel()
    }
  }
}, [searchKeyword, filterOptions?.categoryId, filterOptions?.tags])

完整代码示例

import { useState, useEffect, useRef } from 'react'
import debounce from 'lodash/debounce'

function SearchComponent() {
  const [searchKeyword, setSearchKeyword] = useState('')
  const [filterOptions, setFilterOptions] = useState(null)
  const [results, setResults] = useState([])
  const [loading, setLoading] = useState(false)

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

  // 更新 ref 的值,确保防抖函数总是使用最新的值
  useEffect(() => {
    searchKeywordRef.current = searchKeyword
    filterOptionsRef.current = filterOptions
  }, [searchKeyword, filterOptions])

  // 获取搜索结果列表
  useEffect(() => {
    const fetchSearchResults = async () => {
      // 使用 ref 获取最新的值
      const currentKeyword = searchKeywordRef.current
      const currentFilters = filterOptionsRef.current

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

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

      setLoading(true)
      try {
        const params = {
          keyword: keyword,
          pageSize: 10,
          page: 1,
        }

        // 如果有筛选条件,添加到参数中
        if (currentFilters?.categoryId) {
          params.categoryId = currentFilters.categoryId
        }
        if (currentFilters?.tags) {
          params.tags = currentFilters.tags
        }

        const response = await fetchSearchResults(params)

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

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

        const items = response?.data || []
        setResults(items.slice(0, 10))
      } catch (error) {
        if (currentRequestId !== requestIdRef.current) {
          return
        }
        console.error('获取搜索结果失败:', error)
        setResults([])
      } finally {
        if (currentRequestId === requestIdRef.current) {
          setLoading(false)
        }
      }
    }

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

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

    // 清理函数:组件卸载时取消待执行的防抖调用
    return () => {
      if (debouncedFetchRef.current) {
        debouncedFetchRef.current.cancel()
      }
    }
  }, [searchKeyword, filterOptions?.categoryId, filterOptions?.tags])

  return (
    // ... JSX
  )
}

原理解析

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

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

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

对比分析

// ❌ 错误方式:直接使用闭包变量
useEffect(() => {
  const fetchSearchResults = async () => {
    console.log(searchKeyword)  // 闭包捕获:searchKeyword = "react"(创建时的值)
  }
  const debouncedFn = debounce(fetchSearchResults, 500)
}, [searchKeyword])

// ✅ 正确方式:使用 ref
const searchKeywordRef = useRef(searchKeyword)

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

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

执行流程对比

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

第一次渲染:searchKeyword = "react"
  → fetchSearchResults 闭包捕获 searchKeyword = "react"
  → debounce(fetchSearchResults) 保存引用
  → 防抖函数内部:永远只能访问 "react"

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

正确方式(使用 ref):

第一次渲染:searchKeyword = "react"searchKeywordRef.current = "react"
  → fetchSearchResults 读取 searchKeywordRef.current
  → debounce(fetchSearchResults) 保存引用

第二次渲染:searchKeyword = "react hooks"searchKeywordRef.current = "react hooks"(更新同一个对象属性)
  → 防抖函数执行时,读取 searchKeywordRef.current = "react hooks"

最佳实践

1. 分离 ref 更新逻辑

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

// 更新 ref 的值
useEffect(() => {
  searchKeywordRef.current = searchKeyword
  filterOptionsRef.current = filterOptions
}, [searchKeyword, filterOptions])

2. 防抖函数只创建一次

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

if (!debouncedFetchRef.current) {
  debouncedFetchRef.current = debounce(fetchSearchResults, 500)
}

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

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

return () => {
  if (debouncedFetchRef.current) {
    debouncedFetchRef.current.cancel()
  }
}

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

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

const requestIdRef = useRef<number>(0)

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

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

常见错误

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

// ❌ 错误
useEffect(() => {
  const fetchSearchResults = async () => {
    if (!searchKeyword || !searchKeyword.trim()) {  // 闭包捕获旧值
      return
    }
    // ...
  }
  const debouncedFn = debounce(fetchSearchResults, 500)
}, [searchKeyword])

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

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

错误 3:忘记更新 ref

// ❌ 错误:ref 没有更新
const searchKeywordRef = useRef(searchKeyword)

useEffect(() => {
  const fetchSearchResults = async () => {
    const currentKeyword = searchKeywordRef.current  // 永远是初始值
    // ...
  }
}, [searchKeyword])  // 缺少更新 ref 的 useEffect

总结

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

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

  3. 关键要点

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

参考资料

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