React Hooks下的闭包陷阱

什么是闭包陷阱

闭包陷阱就是我们在React Hooks进行开发时,通过useState定义的值拿到的都不是最新的现象。

useStatereact hooks下最常用的api。 假设有如下的代码:

const App = ()=>{
  const [count,setCount] = useState(0)
  useEffect(()=>{
    const timeId = setInterval(()=>{
      setCount(count+1)
    },1000)
    return ()=>{clearInterval(timeId)}
  },[])
  return (
    <span>{count}</span>
  )
}

count并不会和想象中那样每过一秒就自身+1并更新dom,而是从0变成1后。console打印出来的count一直是我们设立的默认值0

这是为什么呢?

react官方文档Hooks FAQ中有说明: https://react.docschina.org/docs/hooks-faq.html#why-am-i-seeing-stale-props-or-state-inside-my-function

因为我们设置的useEffect的依赖数组为空数组[],那setInterval中的里面的count是通过闭包取得的值,他读取到了第一次的count,并且useEffect并没有更新,因为每次都是0

而如果去掉了useEffect的依赖数组虽然可以解决这个问题,但会造成每次App组件渲染都会运行useEffect里面的函数。这显然会造成不必要的浪费和隐藏的bug。

解决方案(设置新值)

因此有几种解决方案:

使用setState的回调

setCount(count+1)改成setCount(count=>count+1),我们可以使用 setState 的函数式更新形式。它允许我们指定 state 该如何改变而不用引用当前 state,因为回调函数中的参数一直都是最新的count值

用useReducer代替

setCount改成useReducerdispatch,因为useReducerdispatch 的身份永远是稳定的 —— 即使 reducer 函数是定义在组件内部并且依赖 props。代码如下:

const setCountReducer = (state,action)=>{
  switch(action.type){
    case 'add':
      return state+action.value
    case 'minus':
      return state-action.value
    default:
      return state
  }
}

const App = ()=>{
  const [count,dispatch] = useReducer(setCountReducer,0)
  useEffect(()=>{
    const timeId = setInterval(()=>{
      dispatch({type:'add',value:1})
    },1000)
    return ()=> clearInterval(timeId)
  },[])
  return (
    <span>{count}</span>
  )
}

通过useRef()存储变量

因为通过useRef()生成的对象默认都是一个{current:{}},每次组件重新渲染,他也是同一个对象的引用,不会因为组件重新渲染导致取得闭包里面的对象引用。因此它不仅可以绑定dom节点,还可以绑定任意我们想绑定的数据。代码如下:

const App = ()=>{
  const [count,setCount] = useState(0)
  const countRef = useRef()
  countRef.current = count
  useEffect(()=>{
    const timeId = setInterval(()=>{
      setCount(countRef.current+1)
    },1000)
    return ()=> clearInterval(timeId)
  },[])
  return (
    <span>{countRef.current}</span>
  )
}

变量写到外面(不推荐)

useRef()的思路是一样的,但是在组件外引入了一个新变量,不优雅也不安全。不如使用useRef()

let countCopy = 0
const App = ()=>{
  const [count,setCount] = useState(0)
  countCopy = count
  useEffect(()=>{
    const timeId = setInterval(()=>{
      setCount(countCopy+1)
    },1000)
    return ()=> clearInterval(timeId)
  },[])
  return (
    <span>{countCopy}</span>
  )
}

会导致闭包陷阱的几种情况

异步函数

使用setInterval与setTimeout异步函数时,内部的变量读取的是异步函数在运行时组件处在闭包状态下的当前值,因为在异步函数内部的数据并不会在dom更新后更新为新的值。他们的变量引用已经不是同一个了。

const App = ()=>{
  const [count,setCount] = useState(0)
  const consoleCount = ()=>{
    const timeId = setTimeout(()=>{
      console.log(count)
    },2000)
    return ()=> clearTimeout(timeId)
  }
  return (
    <div>
      <span>{count}</span>
      <button onClick={()=>setCount(count+1)}>按我加1</button>
      <button onClick={consoleCount}>输出count</button>
    </div>
  )
}

先点击三次加一按钮把count变成3,然后点击输出count按钮。此时快速点击加一按钮把他变成其他4 5 6,可以看到控制台输出的是3,即输出的count是运行函数时读取的count旧值。

dom监听函数事件中的匿名函数

const App = ()=>{
  const [count,setCount] = useState(0)
  const consoleCount = ()=>{
    console.log(count)
  }
  useEffect(() => {
    window.addEventListener('scroll',consoleCount)
    return () => {
      window.removeEventListener('scroll',consoleCount)
    };
  }, [])

  return (
    <div style={{height:'400vh'}}>
      <span>{count}</span>
      <button onClick={()=>setCount(count+1)}>按我加1</button>
      <button onClick={consoleCount}>输出count</button>
    </div>
  )
}

可以看到不管怎么滚动页面,输出的count永远是默认值0。因为addEventListener只在useEffect初始化的时候绑定了一次,因为执行函数的时候,count读取的是绑定函数时旧的count

解决方案(拿到新值)

useRef()来存储实例变量,同上!这也是react官方推荐的方法。

后续总结

我们并不能认为闭包陷阱是React Hooks的bug,因为它是函数运行时的一个正常的状态。我们应该理解React Hooks在运行中的闭包现象,并根据自身项目的实际情况做相应的修改。这样就不至于在遇到问题时手忙脚乱,摸不着头脑。