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