react闭包的陷阱

218 阅读3分钟

在review同事的代码的时候,发现了一个问题,从而延伸出对react闭包缺陷的思考,在日常的开发中我们还是要尽量避免这个问题的发生。

react闭包陷阱原理

react闭包的陷阱是函数子组件 Hooks 中场景的一种现象。表现为在异步回调或者副作用中捕获到了旧的state或者props值,而不是最新值,究其根本原因是源于 JS 闭包的特性和 React 函数组件渲染机制的结合产生的一种陷阱,没有合理的设置 Hooks 的依赖数组。

js 闭包特性

函数会“记住”并访问它被创建时的词法作用域,即便是后续在其它地方被调用。

React 函数组件渲染机制

组件每次渲染都会经历:

  • 创建新的函数作用域
  • 重新执行组件函数体
  • 生成全新的 state、props和函数

react闭包陷阱的场景

在使用 useEffect hook 的时候,涉及到 回调函数或者使用state的时候,没有合理设置依赖数组,就会出现问题。

场景1:定时器

function App() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval', count)
      setCount(count + 1)
    }, 1000)
    return () => {
      clearInterval(interval)
    }
  }, []) // 依赖数组为空

  console.log('count', count)

  return (
    <div>
      <p>当前Count: {count}</p>
    </div>
  )
}

可以看到以下的执行结果,在 setInterval中拿到的 count 始终是初始化的值。

场景2:回调函数事件监听

function App() {
  const [count, setCount] = useState(0)

  const handleDocument = (event) => {
    console.log('document clicked', count)
  }

  useEffect(() => {
    document.addEventListener('click', handleDocument, false)
    return () => {
      document.removeEventListener('click', handleDocument)
    }
  }, [])

  console.log('render count', count)

  const handleClick = (event) => {
    event.stopPropagation() // 阻止事件冒泡
    setCount(count + 1)
  }

  return (
    <div>
      <p>当前Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  )
}

执行结果如下:多次点击按钮更新count的值,后续点击document的事件,始终获取的是count初始值

如何解决

使用函数式更新

 useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval', count)
      setCount((prev) => prev + 1) // 使用函数式更新
    }, 1000)
    return () => {
      clearInterval(interval)
    }
  }, [])

合理设置依赖项

定时器设置依赖

useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval', count) // 每次获取的是上一次的值
      setCount(count + 1) // 设置新值,触发更新
    }, 1000)
    return () => {
      clearInterval(interval)
    }
  }, [count]) // 依赖count,每次count变化时,重新执行effect

执行结果如下

回调函数使用 useCallback 设置依赖

function App() {
  const [count, setCount] = useState(0)
  // 使用 useCallback 定义回调函数
  const handleDocument = useCallback(() => {
    console.log('document clicked', count)
  }, [count]) // 设置争取的依赖

  useEffect(() => {
    document.addEventListener('click', handleDocument)
    return () => {
      document.removeEventListener('click', handleDocument)
    }
  }, [handleDocument]) // 设置依赖

  const handleClick = (event) => {
    event.stopPropagation() // 阻止事件冒泡
    setCount(count + 1)
  }

  return (
    <div>
      <p>当前Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  )
}

执行结果如下

使用useRef

function App() {
  const [count, setCount] = useState(0)
  const countRef = useRef(count)
  countRef.current = count // 使用ref 保存count的值

  useEffect(() => {
    const interval = setInterval(() => {
      console.log('interval', countRef.current) // 获取ref 的值
    }, 1000)
    return () => {
      clearInterval(interval)
    }
  }, [])

  console.log('render count', count)

  const handleClick = (event) => {
    event.stopPropagation()
    setCount(count + 1)
  }

  return (
    <div>
      <p>当前Count: {count}</p>
      <button onClick={handleClick}>Increment</button>
    </div>
  )
}

执行结果如下

实践总结

  1. 在 useEffect 中使用了 state 和 props 都要声明依赖数组
  2. 在 useEffect 中更新 state 的时候,可以通过函数式更新
  3. 在 useEffect 中使用回调函数,要配合 useCallback,然后声明依赖数组