React State Hooks的闭包陷阱,在使用Hooks以前必须掌握

伴随着 React Hooks 的正式发布,由于其易用性以及对于逻辑代码的复用性更强,毫无疑问愈来愈多的同窗会偏向于使用 Hooks 来写本身的组件。可是随着使用的深刻,咱们发现了一些 State Hooks 的陷阱,那么今天咱们就来分析一下 State Hooks 存在的一些问题,帮助同窗们踩坑。数组

前几天在 twitter 上看到了一个关于 Hooks 的讨论,其内容围绕着下面的 demo:浏览器

掘金上不让外挂代码,因此点击进去看吧闭包

这里的代码想要实现的功能以下:异步

  • 点击 Start 开始执行 interval,而且一旦有可能就往 lapse 上加一
  • 点击 Stop 后取消 interval
  • 点击 Clear 会取消 interval,而且设置 lapse 为 0

可是这个例子在实际执行过程当中会出现一个问题,那就是在 interval 开启的状况下,直接执行 clear,会中止 interval,可是显示的 lapse 却不是 0,那么这是为何呢?函数

出现这样的状况主要缘由是:useEffect 是异步的,也就是说咱们执行 useEffect 中绑定的函数或者是解绑的函数,**都不是在一次 setState 产生的更新中被同步执行的。**啥意思呢?咱们来模拟一下代码的执行顺序:测试

在咱们点击来 clear 以后,咱们调用了 setLapsesetRunning,这两个方法是用来更新 state 的,因此他们会标记组件更新,而后通知 React 咱们须要从新渲染来。ui

而后 React 开始来从新渲染的流程,并很快执行到了 Stopwatch 组件。this

注意以上都是同步执行的过程,因此不会存在在这个过程当中 setInterval 又触发的状况,因此在更新 Stopwatch 的时候,若是咱们能同步得执行 useEffect 的解绑函数,那么就能够在此次 JavaScript 的调用栈中清除这个 interval,而不会出现这种状况。spa

可是偏偏由于 useEffect 是异步执行的,他要在 React 走完本次更新以后才会执行解绑以及从新绑定的函数。那么这就给 interval 再次触发的机会,这也就致使来,咱们设置 lapse 为 0 以后,他又在 interval 中被更新成了一个计算后的值,以后才被真正的解绑。pwa

那么咱们如何解决这个问题呢?

使用 useLayoutEffect

useLayoutEffect 能够看做是 useEffect 的同步版本。使用 useLayoutEffect 就能够达到咱们上面说的,在同一次更新流程中解绑 interval 的目的。

那么同窗们确定要问了,既然 useLayoutEffect 能够避免这个问题,那么为何还要用 useEffect 呢,直接全部地方都用 useLayoutEffect 不就行了。

这个呢主要是由于 useLayoutEffect 是同步的,若是咱们要在 useLayoutEffect 调用状态更新,或者执行一些很是耗时的计算,可能会致使 React 运行时间过长,阻塞了浏览器的渲染,致使一些卡顿的问题。这块呢咱们有机会再单独写一篇文章来分析,这里就再也不赘述。

不使用 useLayoutEffect

固然咱们不能由于 useLayoutEffect 很是方便得解决了问题因此就直接抛弃 useEffect,毕竟这是 React 更推荐的用法。那么咱们该如何解决这个问题呢?

在解决问题以前,咱们须要弄清楚问题的根本。在这个问题上,咱们以前已经分析过,就是由于在咱们设置了 lapse 以后,由于 interval 的再次触发,可是又设置了一次 lapse那么要解决这个问题,就能够经过避免最新的那次触发,或者在触发的时候判断若是没有 running,就再也不设置。

使用 useLayoutEffect 显然属于第一种方法来解决问题,那么咱们接下去来说讲第二种方法。

按照这种思路,咱们第一个反应应该就是在 setInterval 的回调中加入判断:

const intervalId = setInterval(() => {
  if (running) {
    setLapse(Date.now() - startTime)
  }
}, 0)
复制代码

可是很遗憾,这样作是不行的,由于这个回调方法保存了他的闭包,而在他的闭包里面,running 永远都是true。那么咱们是否能够经过在 useEffect 外部声明方法来逃过闭包呢?好比下面这样:

function updateLapse(time) {
  if (runing) {
    setLapse(time)
  }
}

React.useEffect(() => {
  //...
  setInterval(() => {
    updateLapse(/* ... */)
  })
})
复制代码

看上去 updateLapse 使用的是直接外部的 running,因此不是 setInterval 回调保存的闭包来。可是惋惜的是,这也是不行的。由于 updateLapse 也是 setInterval 闭包中的一部分,在这个闭包当中,running 永远都是一开始的值。

可能看到这里你们会有点迷糊,主要就是对于闭包的层次的不太理解,这里我就专门提出来说解一下。

在这里咱们的组件是一个函数组件,他是一个纯粹的函数,没有 this,同理也就没有 this.render 这样的在 ClassComponent 中特有的函数,因此每次咱们渲染函数组件的时候,咱们都是要执行这个方法的,在这里咱们执行 Stopwatch

那么在开始执行的时候,咱们就为 Stopwatch 建立来一个做用域,在这个做用域里面咱们会声明方法,好比 updateLapse,他是在此次执行 Stopwatch 的时候才声明的,每一次执行 Stopwatch 的时候都会声明 updateLapse。一样的,lapserunning 也是每一个做用域里单独声明的,**同一次声明的变量会出于同一个闭包,不一样的声明在不一样的闭包。**而 useEffect 只有在第一次渲染,或者后续 running 变化以后才会执行他的回调,因此对应的回调里面使用的闭包,也是每次执行的那次保存下来的。

这就致使了,在一个 useEffect 内部是没法获知 running 的变化的,这也是 useEffct 提供第二个参数的缘由。

那么是否是这里就无解了呢?明显不是的,这时候咱们须要考虑使用 useReducer 来管理 state

逃出闭包

咱们先来看一下使用 useReducer 实现的代码:

掘金上不让外挂代码,因此点击进去看吧

在这里咱们把 lapserunning 放在一块儿,变成了一个 state 对象,有点相似 Redux 的用法。在这里咱们给 TICK action 上加了一个是否 running 的判断,以此来避开了在 running 被设置为 false 以后多余的 lapse 改变。

那么这个实现跟咱们使用 updateLapse 的方式有什么区别呢?最大的区别是咱们的 state 不来自于闭包,在以前的代码中,咱们在任何方法中获取 lapserunning 都是经过闭包,而在这里,state 是做为参数传入到 Reducer 中的,也就是不论什么时候咱们调用了 dispatch,在 Reducer 中获得的 State 都是最新的,这就帮助咱们避开了闭包的问题。

其实咱们也能够经过 useState 来实现,原理是同样的,咱们能够经过把 lapserunning 放在一个对象中,而后使用

updateState(newState) {
  setState((state) => ({ ...state, newState }))
}
复制代码

这样的方式来更新状态。这里最重要的就是给 setState 传入的是回调,这个回调会接受最新的状态,因此不须要使用闭包中的状态来进行判断。具体的代码我这边就不为你们实现来,你们能够去试一下,最终的代码应该相似下面的(没有测试过):

const [state, dispatch] = React.useState(stateReducer, {
  lapse: 0,
  running: false,
})

function updateState(action) {
  setState(state => {
    switch (action.type) {
      case TOGGLE:
        return { ...state, running: !state.running }
      case TICK:
        if (state.running) {
          return { ...state, lapse: action.lapse }
        }
        return state
      case CLEAR:
        return { running: false, lapse: 0 }
      default:
        return state
    }
  })
}
复制代码

若是有问题很是欢迎跟我讨论哦。

总结

相信看到这里你们应该已经有一些本身的心得了,关于 Hooks 使用上存在的一些问题,最主要的其实就是由于函数组件的特性带来的做用域和闭包问题,一旦你可以理清楚那么你就能够理解不少了。

固然咱们确定不只仅是给你们一些建议,从这个 demo 中咱们也总结出一些最佳实践:

  • 讲相关的 state 最好放到一个对象中进行统一管理
  • 使用更新方法的时候最好使用回调的方式,使用传入的状态,而不要使用闭包中的 state
  • 管理复杂的状态能够考虑使用useReducer,或者相似的方式,对状态操做定义类型,执行不一样的操做。

好了,以上就是这一次的分享,但愿你们能收获必定的经验,避免之后在 Hooks 的使用中出现上面提到的这些问题。

相关文章
相关标签/搜索