若是你玩了几小时的 React Hooks,你可能会陷入一个烦人的问题:在用 setInterval
时总会偏离本身想要的效果。html
这是 Ryan Florence 的原话:react
我已经碰到许多人提到带有 setInterval 的 hooks 时常会打 React 的脸,但由于 stale state 引起的问题我仍是头一次见。 若是在 hooks 中这个问题极其困难,那么相比于 class component,咱们遇到了不一样级别复杂度的问题。git
老实说,我以为这些人是有一套的,至少为此困惑了。github
然而我发现这不是 Hooks 的问题,而是 React编程模型 和 setInterval
不匹配形成的。Hooks 比 class 更贴近 React 编程模型,使这种不匹配更明显。spring
在这篇文章里,咱们会看到 intervals 和 Hooks 是如何玩在一块儿的、为何这个方案有意义和能够提供哪些新的功能。数据库
免责声明:这篇文章的重点是一个 问题样例。即便 API 能够简化上百种状况,议论始终指向更难的问题上。npm
若是你刚入手 Hooks 且不知道这儿在说什么,先查看 这个介绍 和 文档。这篇文章假设你已经使用 Hooks 超过一个小时。编程
不用多说,这是一个每秒递增的计数器:数组
import React, { useState, useEffect, useRef } from 'react';
function Counter() {
let [count, setCount] = useState(0);
useInterval(() => {
// 你本身的代码
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
复制代码
(这是 CodeSandbox demo)。闭包
demo里面的 useInterval
不是一个内置 React Hook,而是一个我写的 custom Hook。
import React, { useState, useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef();
// 保存新回调
useEffect(() => {
savedCallback.current = callback;
});
// 创建 interval
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
复制代码
(这是前面的demo中,你可能错过的 CodeSandbox demo。)
个人 useInterval
Hook 内置了一个 interval 并在 unmounting 的时候清除,它是一个做用在组件生命周期里的 setInterval
和 clearInterval
的组合。
你能够随意将它复制粘贴到项目中或者用 npm 导入。
若是你不在意它是怎么实现的,你能够中止阅读了!接下来的部分是给想深度挖掘 React Hooks 的乡亲们准备的。
我知道你在想什么:
Dan,这段代码根本没什么意思,「单单是 JavaScript」能有什么?认可 React 用 Hooks 钓到了 「鲨鱼」 吧!
一开始我也是这样想的,但后来我改变想法了,我也要改变你的。在解释这段代码为何有意义以前,我想展现下它能作什么。
useInterval()
是更好的API提醒你下,个人 useInterval
Hook 接收 一个 function 和 一个 delay 参数:
useInterval(() => {
// ...
}, 1000);
复制代码
这样看起很像 setInterval
:
setInterval(() => {
// ...
}, 1000);
复制代码
因此为何不直接用 setInterval
呢?
一开始可能不明显,但你发现个人 useInterval
与 setInterval
之间的不一样后,你会看出 它的参数是「动态地」。
我将用具体的例子来讲明这一点。
假设咱们但愿 delay 可调:
虽然你不必定要用到输入控制 delay,但动态调整可能颇有用 —— 例如,用户切换到其余选项卡时,要减小 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} />
</>
);
}
}
复制代码
(这是 CodeSandbox demo。)
这样也不错!
Hook 版本看起来是什么样子的?
🥁🥁🥁
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} />
</>
);
}
复制代码
(这是 CodeSandbox demo。)
是的,这就是所有了。
不像 class 的版本,useInterval
Hook 例子中,「更新」成动态调整 delay 很简单:
// 固定 delay
useInterval(() => {
setCount(count + 1);
}, 1000);
// 可调整 delay
useInterval(() => {
setCount(count + 1);
}, delay);
复制代码
当 useInterval
Hook 接收到不一样 delay,它会重设 interval。
声明一个带有动态调整 delay 的 interval,来替代写 添加和清除 interval 的代码 —— useInterval
Hook 帮咱们作到了。
若是我想暂时 暂停 interval 要怎么作?我能够用一个 state 来作到:
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
复制代码
(这是 demo!)
这让我对 React 和 Hooks 再次感到兴奋。咱们能够包装现有的命令式 APIs 和建立更贴近表达咱们意图的声明式 APIs。就拿渲染来讲,咱们能够同时准确地描述每一个时间点过程,而不用当心地用指令来操做它。
我但愿到这里大家开始以为 useInterval()
Hook 是一个更好的 API 了 —— 至少和组件比。
但为何在 Hooks 中使用 setInterval()
和 clearInterval()
让人心烦呢?让咱们回到计数器例子并试着手动实现它。
我会从一个只渲染初始状态的简单例子开始:
function Counter() {
const [count, setCount] = useState(0);
return <h1>{count}</h1>;
}
复制代码
如今我想要一个每秒增长的 interval,它是一个须要清理反作用的,因此我将用到 useEffect()
并返回清理函数:
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <h1>{count}</h1>;
}
复制代码
(查看 CodeSandbox demo.)
这种工做看起来很简单对吧?
可是,这代码有一个奇怪的行为。
默认状况下,React 会在每次渲染后重执行 effects,这是有目的的,这有助于避免 React class 组件的某种 bugs。
这一般是好的,由于须要许多订阅 API 能够随时顺手移除老的监听者和加个新的。可是,setInterval
和它们不同。当咱们执行 clearInterval
和 setInterval
时,它们会进入时间队列里,若是咱们频繁重渲染和重执行 effects,interval 有可能没有机会被执行!
咱们能够经过以更短间隔重渲染咱们的组件,来发现这个 bug:
setInterval(() => {
// 重渲染和重执行 Counter 的 effects
// 这里会发生 clearInterval()
// 在 interval 被执行前 setInterval()
ReactDOM.render(<Counter />, rootElement); }, 100); 复制代码
(看这个 bug 的 demo)
你可能知道 useEffect()
容许咱们选择性地进行重执行 effects,你能够设定一个依赖数组做为第二个参数,React 只会在数组里的某个发生变化时重运行:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
复制代码
当咱们 只 想在 mount 时执行 effect 和 unmount 时清理它,咱们能够传空 []
的依赖数组。
可是,若是你不熟悉 JavaScript 的闭包,会碰到一个常见的错误。咱们如今就来制造这个错误!(咱们还创建了一个尽早反馈这个错误的 lint 规则,但还没准备好。)
在第一次尝试中,咱们的问题是重运行 effects 时使得 timer 过早被清除,咱们能够尝试不重运行去修复它们:
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
在第一次渲染时获取值为 0 的 count
,咱们再也不重执行 effect,因此 setInterval
一直引用第一次渲染时的闭包 count
,以致于 count + 1
一直是 1
。哎呀呀!
我能够听见你咬牙切齿了,Hooks 真烦人对吧?
修复它的一种方法是用像 setCount(c => c + 1)
这样的 「updater」替换 setCount(count + 1)
,这样能够读到新 state 变量。但这个没法帮助你获取到新的 props。
另外一个方法是用 useReducer()
。这种方法为你提供了更大的灵活性。在 reducer 中,你能够访问到当前 state 和新的 props。dispatch
方法自己永远不会改变,因此你能够从任何闭包中将数据放入其中。useReducer()
有个约束是你不能够用它执行反作用。(可是,你能够返回新状态 —— 触发一些 effect。)
但为何要变得这么复杂?
这个术语有时会被提到,Phil Haack 解释以下:
有人说数据库来自火星而对象来自金星,数据库不会天然地映射到对象模型。这很像试图将磁铁的两极推到一块儿。
咱们的「阻抗匹配」不在数据库和对象之间,它在 React 编程模型和命令式 setInterval
API 之间。
一个 React 组件可能在 mounted 以前流经许多不一样的 state,但它的渲染结果将一次性所有描述出来。
// 描述每次渲染
return <h1>{count}</h1>
复制代码
Hooks 使咱们把相同的声明方法用在 effects 上:
// 描述每一个间隔状态
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
复制代码
咱们不设置 interval,但指定它是否设置延迟或延迟多少,咱们的 Hooks 作到了,用离散术语描述连续过程
相反,setInterval
没有及时地描述过程 —— 一旦设定了 interval,除了清除它,你没法对它作任何改变。
这就是 React 模型和 setInterval
API 之间的不匹配。
React 组件中的 props 和 state 是能够改变的, React 会重渲染它们且「丢弃」任何关于上一次渲染的结果,它们之间再也不有相关性。
useEffect()
Hook 也「丢弃」上一次渲染结果,它会清除上一次 effect 再创建下一个 effect,下一个 effect 锁住新的 props 和 state,这也是咱们第一次尝试简单示例能够正确工做的缘由。
但 setInterval
不会「丢弃」。 它会一直引用老的 props 和 state 直到你把它换掉 —— 不重置时间你是没法作到的。
或者等等,你能够作到?
这个问题归结为下面这样:
callback1
的 setInterval(callback1, delay)
。callbaxk2
。那么若是咱们根本不替换 interval,而是引入一个指向新 interval 回调的可变 savedCallback
会怎么样?
如今咱们来看看这个方案:
setInterval(fn, delay)
,其中 fn
调用 savedCallback
。savedCallback
设为 callback1
。savedCallback
设为 callback2
。这个可变的 savedCallback
须要在从新渲染时「可持续(persist)」,因此不能够是一个常规变量,咱们想要一个相似实例的字段。
正如咱们从 Hooks FAQ 中学到的,useRef()
给出了咱们想要的结果:
const savedCallback = useRef();
// { current: null }
复制代码
(你可能熟悉 React 中的 DOM refs)。Hooks 使用相同的概念来保存任意可变值。ref 就像一个「盒子」,你能够听任何东西
useRef()
返回一个有带有 current
可变属性的普通对象在 renders 间共享,咱们能够保存新的 interval 回掉给它:
function callback() {
// 能够读到新 props,state等。
setCount(count + 1);
}
// 每次渲染后,保存新的回调到咱们的 ref 里。
useEffect(() => {
savedCallback.current = callback;
});
复制代码
以后咱们即可以从咱们的 interval 中读取和调用它:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
复制代码
感谢 []
,不重执行咱们的 effect,interval 就不会被重置。同时,感谢 savedCallback
ref,让咱们能够一直在新渲染以后读取到回调,并在 interval tick 里调用它。
这是完整的解决方案:
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 demo。)
不能否认,上面的代码使人困惑,混合相反的范式使人费解,还可能弄乱可变 refs。
我以为 Hooks 提供了比 class 更低级的原语 —— 但它们的美丽在于它们使咱们可以创做并创造出更好的陈述性抽象。
理想状况下,我只想这样写:
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
复制代码
我将我 ref 机制的代码复制粘贴到一个 custom 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
delay 是写死的,我想把它变成一个参数:
function useInterval(callback, delay) {
复制代码
我会在建立好 interval 后使用它:
let id = setInterval(tick, delay);
复制代码
如今 delay
能够在 renders 之间改变,我须要在个人 interval effect 依赖部分声明它:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
复制代码
等等,咱们不是要避免重置 interval effect,并专门经过 []
来避免它吗?不彻底是,咱们只想在回调改变时避免重置它,但当 delay
改变时,咱们想要重启 timer!
让咱们检查下咱们的代码是否有效:
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()
的实现过程,在任意组件中使用它。
假设咱们但愿可以经过传递 null
做为 delay
来暂停咱们的 interval:
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
复制代码
如何实现这个?答案时:不建立 interval。
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
复制代码
(看 CodeSandbox demo。)
就是这样。此代码处理了全部可能的变化:改变 delay、暂停、或者恢复 interval。useEffect()
API 要求咱们花费更多的前期工做来描述创建和清除 —— 但添加新案例很容易。
useInterval()
Hook 真的很好玩,当反作用是陈述性的,将复杂的行为编排在一块儿要容易得多。
例如:咱们 interval 中 delay
能够受控于另一个:
function Counter() {
const [delay, setDelay] = useState(1000);
const [count, setCount] = useState(0);
// 增长计数器
useInterval(() => {
setCount(count + 1);
}, delay);
// 每秒加速
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>
</>
);
}
复制代码
(看 CodeSandbox demo!)
Hooks 须要花时间去习惯 —— 特别是在跨越命令式和声明式的代码上。你能够建立像 React Spring 同样的抽象,但有时它们会让你不安。
Hooks 还处于前期阶段,无疑此模式仍须要修炼和比较。若是你习惯跟随众所周知的「最佳实践」,不要急于采用 Hooks,它须要不少的尝试和探索。
我但愿这篇文章能够帮助你理解带有 setInterval()
等 API 的 Hooks 的相关常见问题、能够帮助你克服它们的模式、及享用创建在它们之上更具表达力的声明式 APIs 的甜蜜果实。
翻译原文Making setInterval Declarative with React Hooks(2019-02-04)