useEffect原理深入解析从依赖数组到清理函数的完整实现机制
useEffect里的异步函数,踩了个大坑
今天又在useEffect里踩坑了,这次是关于异步函数的问题。直接说结论吧,useEffect的回调函数不能直接返回一个async函数,React会给你报错。
// 错误写法
useEffect(async () => {
const data = await fetch('/api/data')
setData(data)
}, [])
这里我就被坑了,想当然地觉得直接写async应该没问题。结果React提示”Effect function must not return anything besides a function”,因为async函数总会返回一个Promise,而不是清理函数。
各种尝试都失败了
刚开始我想着既然不能直接return Promise,那就包一层普通函数呗:
useEffect(() => {
async function fetchData() {
const data = await fetch('/api/data')
setData(data)
}
fetchData()
}, [])
看起来没问题,运行也正常。但是!后来我发现了一个问题,如果组件卸载时请求还在pending状态,可能会导致内存泄漏。网上查了下,这种情况确实需要处理取消信号。
折腾了半天发现,还得给fetch加个AbortController:
useEffect(() => {
const abortController = new AbortController()
async function fetchData() {
try {
const response = await fetch('/api/data', {
signal: abortController.signal
})
if (!abortController.signal.aborted) {
const data = await response.json()
setData(data)
}
} catch (error) {
if (error.name !== 'AbortError') {
console.error('Fetch error:', error)
}
}
}
fetchData()
return () => {
abortController.abort()
}
}, [])
写完这套流程感觉有点繁琐,后来试了下把异步逻辑抽出来单独定义:
const fetchData = useCallback(async (signal) => {
try {
const response = await fetch('/api/data', { signal })
if (!signal.aborted) {
const data = await response.json()
return data
}
} catch (error) {
if (error.name !== 'AbortError') {
throw error
}
}
}, [])
useEffect(() => {
const abortController = new AbortController()
fetchData(abortController.signal).then(setData)
return () => {
abortController.abort()
}
}, [fetchData])
最简洁的解决方案
不过后来发现,其实有个更简单的写法,就是创建一个立即执行的async函数:
useEffect(() => {
let cancelled = false
;(async () => {
try {
const response = await fetch('/api/data')
const data = await response.json()
if (!cancelled) {
setData(data)
}
} catch (error) {
if (!cancelled) {
setError(error)
}
}
})()
return () => {
cancelled = true
}
}, [])
这里用了一个cancelled标志位来避免组件卸载后更新state。虽然不如AbortController那么标准,但代码确实清爽了不少。亲测有效,而且不会有内存泄漏的风险。
依赖项的坑也不能忽视
还有个容易忽略的地方,就是当useEffect里面调用了useCallback定义的函数时:
const getData = useCallback(async () => {
const response = await fetch('/api/data')
return response.json()
}, [someDependency])
useEffect(() => {
let cancelled = false
;(async () => {
const data = await getData()
if (!cancelled) {
setData(data)
}
})()
return () => {
cancelled = true
}
}, [getData]) // 这里要记得加getData依赖
这里我踩过好几次坑,忘记把getData加到依赖数组里,导致useEffect一直用的是旧的数据。React的闭包机制就是这样的,不加依赖就会引用老的变量。
eslint插件救大命
说到依赖数组,一定要装eslint-plugin-react-hooks,这个插件能帮你检查依赖是否完整:
{
"extends": [
"react-app",
"react-app/jest",
"plugin:react-hooks/recommended"
]
}
有了这个插件,上面的代码会被警告缺少依赖。虽然有时候插件的建议不一定对,但对于useEffect的依赖检查确实很有帮助。
多个异步请求的处理
实际项目中经常有多个异步请求的情况,比如同时获取用户信息和列表数据:
useEffect(() => {
let cancelled = false
const fetchAllData = async () => {
try {
const [userData, listData] = await Promise.all([
fetch('/api/user').then(r => r.json()),
fetch('/api/list').then(r => r.json())
])
if (!cancelled) {
setUser(userData)
setList(listData)
}
} catch (error) {
if (!cancelled) {
setError(error)
}
}
}
fetchAllData()
return () => {
cancelled = true
}
}, []) // 注意这里依赖数组是空的,因为fetch地址是固定的
这样写的好处是一次性获取所有数据,减少加载状态的闪烁。但要注意Promise.all的特点,任何一个请求失败都会导致整个Promise失败。
踩坑提醒:这几点一定注意
总结一下useEffect异步函数的几个要点:
- useEffect回调不能直接返回async函数
- 组件卸载时要处理pending的请求,避免内存泄漏
- 依赖数组一定要完整,否则可能引用过期的数据
- 多个异步请求可以用Promise.all优化
- 出错处理要考虑取消信号的情况
这些坑我都踩过,有的还是踩了好几次才记住。现在基本形成了肌肉记忆,写useEffect异步逻辑的时候都会按照这个模板来,虽然看起来复杂了点,但至少不会出bug。
以上是我踩坑后的总结,希望对你有帮助。

暂无评论