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])
问题表现
当用户快速输入时,比如:
- 输入 "react" →
searchKeyword = "react" - 继续输入 "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])
为什么会出现这个问题?
- 防抖函数只创建一次:为了保持防抖效果,我们通常会将防抖函数保存在
useRef中,只创建一次 - 闭包捕获旧值:第一次创建防抖函数时,
fetchSearchResults通过闭包捕获了当时的searchKeyword值 - 后续更新无效:即使
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 的特性:
- ref 是可变的对象属性:
ref.current是一个对象的属性,不是闭包变量 - 读取时总是最新值:每次读取
ref.current时,获取的都是当前最新的值 - 不受闭包影响:即使函数通过闭包捕获了
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
总结
-
问题根源:防抖函数只创建一次,但内部函数通过闭包捕获了创建时的状态值,导致后续状态更新无法反映到防抖函数中。
-
解决方案:使用
useRef保存最新的状态值,在防抖函数中通过ref.current访问最新值,避免闭包陷阱。 -
关键要点:
- 防抖函数只创建一次,保持防抖效果
- 通过 ref 访问最新状态,避免闭包陷阱
- 及时更新 ref 的值
- 正确处理清理逻辑
-
适用场景:所有需要在防抖/节流函数中访问最新状态的场景,如搜索输入、滚动事件处理、窗口大小变化等。
参考资料
最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!