本文由云+社区发表做者:Dan Abramovhtml
接触 React Hooks 必定时间的你,也许会碰到一个神奇的问题: setInterval
用起来没你想的简单。react
Ryan Florence 在他的推文里面说到:git
很多朋友跟我提起,setInterval 和 hooks 一块儿用的时候,有种蛋蛋的忧伤。
老实说,这些朋友也不是胡扯。刚开始接触 Hooks 的时候,确实还挺让人疑惑的。github
但我认为谈不上 Hooks 的毛病,而是 React 编程模型和 setInterval
之间的一种模式差别。相比类(Class),Hooks 更贴近 React 编程模型,使得这种差别更加突出。spring
虽然有点绕,可是让二者和谐相处的方法,仍是有的。数据库
本文就来探索一下,如何让 setInterval 和 Hooks 和谐地玩耍,为何是这种方式,以及这种方式给你带来了什么新能力。编程
声明:本文采用按部就班的示例来解释问题。因此有一些示例虽然看起来能够有捷径可走,可是咱们仍是一步步来。数组
若是你是 Hooks 新手,不太明白我在纠结啥,不妨读一下 React Hooks 的介绍和官方文档。本文假设读者已经使用 Hooks 超过一个小时。闭包
经过下面的方式,咱们能够轻松地实现一个每秒自增的计数器:dom
import React, { useState, useEffect, useRef } from 'react'; function Counter() { let [count, setCount] = useState(0); useInterval(() => { // Your custom logic here setCount(count + 1); }, 1000); return <h1>{count}</h1>; }
上述 useInterval
并非内置的 React Hook,而是我实现的一个自定义 Hook:
import React, { useState, useEffect, useRef } from 'react'; function useInterval(callback, delay) { const savedCallback = useRef(); // Remember the latest callback. useEffect(() => { savedCallback.current = callback; }); // Set up the interval. useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); }
(若是你在错过了,这里也有一个同样的 CodeSandbox 线上示例)
我实现的 useInterval Hook 设置了一个计时器,而且在组件 unmount 的时候清理掉了。 这是经过组件生命周期上绑定 setInterval
与 clearInterval
的组合完成的。
这是一份能够在项目中随意复制粘贴的实现,你甚至能够发布到 NPM 上。
不关心为何这样实现的读者,就不用继续阅读了。下面的内容是为但愿深刻理解 React Hooks 的读者而准备的。
我知道你想什么:
Dan,这代码不对劲。说好的“纯粹 JavaScript”呢?React Hooks 打了 React 哲学的脸?
哈,我一开始也是这么想的,可是后来我改观了,如今,我准备也改变你的想法。开始以前,我先介绍下这份实现的能力。
useInterval()
是一个更合理的 API?注意下,useInterval
Hook 接收一个函数和一个延时做为参数:
useInterval(() => { // ... }, 1000);
这个跟原生的 setInterval
很是的类似:
setInterval(() => { // ... }, 1000);
那为啥不干脆使用 setInterval 呢?
setInterval
和 useInterval
Hook 最大的区别在于,useInterval
Hook 的参数是“动态的”。乍眼一看,可能不是那么明显。
我将经过一个实际的例子来讲明这个问题:
若是咱们但愿 interval 的间隔是可调的:
一个延时可输入的计时器
此时无需手动控制延时,直接动态调整 Hooks 参数就好了。比方说,咱们能够在用户切换到另外一个选项卡时,下降 AJAX 更新数据的频率。
若是按照类(Class)的方式,怎么经过 setInterval
实现上述需求呢?我折腾出这个:
class Counter extends React.Component { state = { count: 0, delay: 1000, }; componentDidMount() { this.interval = setInterval(this.tick, this.state.delay); } componentDidUpdate(prevProps, prevState) { if (prevState.delay !== this.state.delay) { clearInterval(this.interval); this.interval = setInterval(this.tick, this.state.delay); } } componentWillUnmount() { clearInterval(this.interval); } tick = () => { this.setState({ count: this.state.count + 1 }); } handleDelayChange = (e) => { this.setState({ delay: Number(e.target.value) }); } render() { return ( <> <h1>{this.state.count}</h1> <input value={this.state.delay} onChange={this.handleDelayChange} /> </> ); } }
太熟悉了!
那改为使用 Hooks 怎么实现呢?
🥁🥁🥁表演开始了!
function Counter() { let [count, setCount] = useState(0); let [delay, setDelay] = useState(1000); useInterval(() => { // Your custom logic here setCount(count + 1); }, delay); function handleDelayChange(e) { setDelay(Number(e.target.value)); } return ( <> <h1>{count}</h1> <input value={delay} onChange={handleDelayChange} /> </> ); }
没了,就这么多!
不用于 class 实现的版本,useInterval
Hook “升级到”支持到支持动态调整延时的版本,没有增长任何复杂度。
使用 useInterval
新增动态延时能力,几乎没有增长任何复杂度。这个优点是使用 class 没法比拟的。
// 固定延时 useInterval(() => { setCount(count + 1); }, 1000); // 动态延时 useInterval(() => { setCount(count + 1); }, delay);
当 useInterval
接收到另外一个 delay 的时候,它就会从新设置计时器。
咱们并无经过执行代码来设置或者清理计时器,而是声明了具备特定延时的计时器 - 这是咱们实现的 useInterval 的根本缘由。
若是想临时暂停计时器呢?我能够这样来:
const [delay, setDelay] = useState(1000); const [isRunning, setIsRunning] = useState(true); useInterval(() => { setCount(count + 1); }, isRunning ? delay : null);
(线上示例)
这就是 Hooks 和 React 再一次让我兴奋的缘由。咱们能够把原有的调用式 API,包装成声明式 API,从而更加贴切地表达咱们的意图。就跟渲染同样,咱们能够描述当前时间每一个点的状态,而无需当心翼翼地经过具体的命令来操做它们。
到这里,我但愿你已经确信 useInterval
Hook 是一个更好的 API - 至少在组件层面使用的时候是这样。
但是为何在 Hooks 里使用 setInterval 和 clearInterval 这么让人恼火? 回到刚开始的计时器例子,咱们尝试手动去实现它。
最简单的,渲染初始状态:
function Counter() { const [count, setCount] = useState(0); return <h1>{count}</h1>; }
如今我但愿它每秒定时更新。我准备使用 useEffect()
而且返回一个清理方法,由于它是一个须要清理的 Side Effect:
function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }); return <h1>{count}</h1>; }
(查看 CodeSandbox 线上示例)
看起来很简单?
然而,这段代码有个诡异的行为。
React 默认会在每次渲染时,都从新执行 effects。这是符合预期的,这机制规避了早期在 React Class 组件中存在的一系列问题。
一般来讲,这是一个好特性,由于大部分的订阅 API 都容许移除旧的订阅并添加一个新的订阅来替换。可是,这不包括 setInterval
。调用了 clearInterval
后从新 setInterval
的时候,计时会被重置。若是咱们频繁从新渲染,致使 effects 频繁执行,计时器可能根本没有机会被触发!
经过使用在一个更小的时间间隔从新渲染咱们的组件,能够重现这个 BUG:
setInterval(() => { // 从新渲染致使的 effect 从新执行会让计时器在调用以前, // 就被 clearInterval() 清理掉,以后 setInterval() // 从新设置的计时器,会从新开始计时 ReactDOM.render(<Counter />, rootElement); }, 100);
(查看这个 BUG 的线上示例)
部分读者可能知道,useEffect
容许咱们控制从新执行的实际。经过在第二个参数指定依赖数组,React 就会只在这个依赖数组变动的时候从新执行 effect。
useEffect(() => { document.title = `You clicked ${count} times`; }, [count]);
若是咱们但愿 effect 只在组件 mount 的时候执行,而且在 unmount 的时候清理,咱们能够传递空数组 []
做为依赖。
可是!不是特别熟悉 JavaScript 闭包的读者,极可能会犯一个共性错误。我来示范一下!(咱们在设计 lint 规则来帮助定位此类错误,不过如今尚未准备好。)
第一次的问题在于,effect 的从新执行致使计时器太早被清理掉了。若是不从新执行它们,也许能够解决这个问题:
function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
若是这样实现,计时器更新到 1 以后,就中止不动了。(查看这个 BUG 的线上示例)
发生了啥?
问题在于,useEffect 使用的 count 是在第一次渲染的时候获取的。 获取的时候,它就是 0
。因为一直没有从新执行 effect,因此 setInterval
在闭包中使用的 count
始终是从第一次渲染时来的,因此就有了 count + 1
始终是 1
的现象。呵呵哒!
我感受你已经开始怼天怼地了。Hooks 是什么鬼嘛!
解决这个问题的一个方案,是把 setCount(count + 1)
替换成“更新回调”的方式 setCount(c => c + 1)
。从回调参数中,能够获取到最新的状态。此非万全之策,新的 props 就没法读取到。
另外一个解决方案是使用 useReducer()
。此方案更为灵活。在 reducer 内部,能够访问当前的状态,以及最新的 props。dispatch
方法自己不会改变,因此你能够在闭包里往里面灌任何数据。使用 useReducer()
的一个限制是,你不能在内部触发 effects。(不过,你是能够经过返回一个新 state 来触发一些 effect)。
为什么如此艰难?
这个术语(译者注:术语原文为 "Impedance Mismatch")在不少地方被你们使用,Phil Haack 是这样解释的:
有人说数据库来自火星,对象来自金星。数据库不能自然的和对象模型创建映射关系。这就像尝试将两块磁铁的 N 极挤在一块儿同样。
咱们此处的“阻抗不匹配”,说的不是数据库和对象。而是 React 编程模型,与命令式的 setInterval
API 之间的不匹配。
一个 React 组件可能会被 mount 一段时间,而且经历多个不一样的状态,不过它的 render 结果一次性地描述了全部这些状态
// 描述了每一次渲染的状态 return <h1>{count}</h1>
同理,Hooks 让咱们声明式地使用一些 effect:
// 描述每个计数器的状态 useInterval(() => { setCount(count + 1); }, isRunning ? delay : null);
咱们不须要去设置计时器,可是指明了它是否应该被设置,以及设置的间隔是多少。咱们事先的 Hook 就是这么作的。经过离散的声明,咱们描述了一个连续的过程。
相对应的,setInterval 却没有描述到整个过程 - 一旦你设置了计时器,它就没法改变了,只能清除它。
这就是 React 模型和 setInterval
API 之间的“阻抗不匹配”。
React 组件的 props 和 state 会变化时,都会被从新渲染,而且把以前的渲染结果“忘记”的一干二净。两次渲染之间,是互不相干的。
useEffect()
Hook 一样会“遗忘”以前的结果。它清理上一个 effect 而且设置新的 effect。新的 effect 获取到了新的 props 和 state。因此咱们第一次的事先在某些简单的状况下,是能够执行的。
可是 setInterval() 不会 “忘记”。 它会一直引用着旧的 props 和 state,除非把它换了。可是只要把它换了,就无法不从新设置时间了。
等会,真的不能吗?
先把问题整理下:
callback1
进行 setInterval(callback1, delay)
callback2
能够访问到新的 props 和 state若是咱们压根不替换计时器,而是传入一个 savedCallback 变量,始终指向最新的计时器回调呢??
如今咱们的方案看起来是这样的:
setInterval(fn, delay)
,其中 fn
调用 savedCallback
。savedCallback
为 callback1
savedCallback
为 callback2
可变的 savedCallback
须要在屡次渲染之间“持久化”,因此不能使用常规变量。咱们须要像相似实例字段的手段。
从 Hooks 的 FAQ 中,咱们得知 useRef()
能够帮咱们作到这点:
const savedCallback = useRef(); // { current: null }
(你可能已经对 React 的 DOM refs 比较熟悉了。Hooks 引用了相同的概念,用于持有任意可变的值。一个 ref 就行一个“盒子”,能够放东西进去。)
useRef()
返回了一个字面量,持有一个可变的 current
属性,在每一次渲染之间共享。咱们能够把最新的计时器回调保存进去。
function callback() { // 能够读取到最新的 state 和 props setCount(count + 1); } // 每次渲染,保存最新的回调到 ref 中 useEffect(() => { savedCallback.current = callback; });
后续就能够在计时器回调中调用它了:
useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); }, []);
因为传入了 []
,咱们的 effect 不会从新执行,因此计时器不会被重置。另外一方面,因为设置了 savedCallback
ref,咱们能够获取到最后一次渲染时设置的回调,而后在计时器触发时调用。
再看一遍完整的实现:
function Counter() { const [count, setCount] = useState(0); const savedCallback = useRef(); function callback() { setCount(count + 1); } useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); }, []); return <h1>{count}</h1>; }
(查看 CodeSandbox 线上示例)
不得不认可,上面的代码有点迷。各类花里胡哨的操做让人费解不说,还有可能让 state 和 refs 与其它逻辑里的搞混。
我认为,虽然 Hooks 相比 Class 提供了更底层的能力 - 不过 Hooks 的牛逼在于容许咱们重组、抽象后创造出声明语意更优的 Hooks
事实上,我就想这样来写:
function Counter() { const [count, setCount] = useState(0); useInterval(() => { setCount(count + 1); }, 1000); return <h1>{count}</h1>; }
因而我把个人实现核心拷贝到自定义 Hook 中:
function useInterval(callback) { const savedCallback = useRef(); useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); }, []); }
延时值 1000
是硬编码的,把它参数化:
function useInterval(callback, delay) {
在设置计时器的时候使用:
let id = setInterval(tick, delay);
如今 delay
可能在屡次渲染之间变动,我须要把它声明为计时器 effect 的依赖:
useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, delay); return () => clearInterval(id); }, [delay]);
慢着,咱们以前不是为了不计时器重设,才传入了一个 []
的吗?不彻底是。咱们只是但愿 Hooks 不要在 callback 变动的从新执行。若是 delay
变动了,咱们是想要从新启动计时器的。
如今来看下咱们的代码是否是能跑:
function Counter() { const [count, setCount] = useState(0); useInterval(() => { setCount(count + 1); }, 1000); return <h1>{count}</h1>; } function useInterval(callback, delay) { const savedCallback = useRef(); useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, delay); return () => clearInterval(id); }, [delay]); }
(读者能够在 CodeSandbox 上试一下)
棒棒的!如今,咱们能够无需关注实现细节,在任何组件里面须要的时候,直接使用 useInterval()
了。
咱们但愿在给 delay
传 null
的时候暂停计时器:
const [delay, setDelay] = useState(1000); const [isRunning, setIsRunning] = useState(true); useInterval(() => { setCount(count + 1); }, isRunning ? delay : null);
怎么实现?简单:不设置计时器就能够了。
useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]);
就这样了。这段代码能够处理各类可能的变动了:延时值改变、暂停和继续。虽然 useEffect()
API 须要咱们前期花更多的精力进行设置和清理工做,添加新能力倒是轻松了。
这个 useInterval()
Hook 其实很好玩。如今 side effects 是声明式的,因此组合使用变得轻松多了。
比方说,咱们可使用一个计时器来控制另外一个计时器的 delay:
自动加速的计时器
function Counter() { const [delay, setDelay] = useState(1000); const [count, setCount] = useState(0); // Increment the counter. useInterval(() => { setCount(count + 1); }, delay); // Make it faster every second! useInterval(() => { if (delay > 10) { setDelay(delay / 2); } }, 1000); function handleReset() { setDelay(1000); } return ( <> <h1>Counter: {count}</h1> <h4>Delay: {delay}</h4> <button onClick={handleReset}> Reset delay </button> </> ); }
Hooks 须要咱们慢慢适应 - 尤为是在面对命令式和声明式代码的区别时。你能够创造出像 React Spring 同样强大的声明式抽象,可是他们复杂的用法偶尔会让你紧张。
Hooks 还很年轻,还有不少咱们能够研究和对比的模式。若是你习惯于按照“最佳实践”来的话,大可没必要着急使用 Hooks。社区还需时间来尝试和挖掘更多的内容。
使用 Hooks 的时候,涉及到相似 setInterval()
的 API,会碰到一些问题。阅读本文后,但愿读者可以理解而且解决它们,同时,经过建立更加语义化的声明式 API,享受其带来的好处。
此文已由腾讯云+社区在各渠道发布
获取更多新鲜技术干货,能够关注咱们腾讯云技术社区-云加社区官方号及知乎机构号