useEffect原理深入解析从依赖数组到清理函数的完整实现机制

程序猿梓轩 框架 阅读 537
赞 10 收藏
二维码
手机扫码查看
反馈

useEffect里的异步函数,踩了个大坑

今天又在useEffect里踩坑了,这次是关于异步函数的问题。直接说结论吧,useEffect的回调函数不能直接返回一个async函数,React会给你报错。

useEffect原理深入解析从依赖数组到清理函数的完整实现机制

// 错误写法
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。

以上是我踩坑后的总结,希望对你有帮助。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论