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

10,250 阅读7分钟

伴随着 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 我们需要重新渲染来。

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

注意以上都是同步执行的过程,所以不会存在在这个过程中 setInterval 又触发的情况,所以在更新 Stopwatch 的时候,如果我们能同步得执行 useEffect 的解绑函数,那么就可以在这次 JavaScript 的调用栈中清除这个 interval,而不会出现这种情况。

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

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

使用 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 的使用中出现上面提到的这些问题。