伴随着 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
之后,我们调用了 setLapse
和 setRunning
,这两个方法是用来更新 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
。同样的,lapse
和 running
也是每个作用域里单独声明的,**同一次声明的变量会出于同一个闭包,不同的声明在不同的闭包。**而 useEffect
只有在第一次渲染,或者后续 running
变化之后才会执行他的回调,所以对应的回调里面使用的闭包,也是每次执行的那次保存下来的。
这就导致了,在一个 useEffect
内部是无法获知 running
的变化的,这也是 useEffct
提供第二个参数的原因。
那么是不是这里就无解了呢?明显不是的,这时候我们需要考虑使用 useReducer
来管理 state
逃出闭包
我们先来看一下使用 useReducer
实现的代码:
掘金上不让外挂代码,所以点击进去看吧
在这里我们把 lapse
和 running
放在一起,变成了一个 state
对象,有点类似 Redux 的用法。在这里我们给 TICK
action 上加了一个是否 running
的判断,以此来避开了在 running
被设置为 false
之后多余的 lapse
改变。
那么这个实现跟我们使用 updateLapse
的方式有什么区别呢?最大的区别是我们的 state
不来自于闭包,在之前的代码中,我们在任何方法中获取 lapse
和 running
都是通过闭包,而在这里,state
是作为参数传入到 Reducer 中的,也就是不论何时我们调用了 dispatch
,在 Reducer 中得到的 State 都是最新的,这就帮助我们避开了闭包的问题。
其实我们也可以通过 useState
来实现,原理是一样的,我们可以通过把 lapse
和 running
放在一个对象中,然后使用
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 的使用中出现上面提到的这些问题。