用 React Hooks 声明式地使用 setInterval
February 4, 2019
如果你用 React Hooks 玩过几个小时,可能已经遇到过一个有趣的问题:setInterval
的用法并不像你预期的那样 正常工作。
用 Ryan Florence 的原话来说:
很多人把 hooks 里的 setInterval 当作 React 的一个“黑点”来指出。
说实话,我觉得这些人说得有道理。一开始确实让人困惑。
但我后来也意识到,这并不是 Hooks 的缺陷,而是 React 编程模型 和 setInterval
之间的不匹配。Hooks 比类组件更贴近 React 的编程模型,这种不匹配也因此更加明显。
其实 有 一种方法能让它们很好地协作,但这方法有点反直觉。
在这篇文章里,我们会一起看看如何让 interval 和 Hooks 配合得很好、为什么这种方案合理,以及它还能带来哪些新能力。
免责声明:本文聚焦于一个_极端案例_。即使某个 API 能简化上百种用法,讨论时总会聚焦在那个变得更难的场景。
如果你刚接触 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>;
}
这里的 useInterval
不是 React 内置的 Hook,而是我写的一个自定义 Hook:
import React, { useState, useEffect, useRef } from 'react';
function useInterval(callback, delay) {
const savedCallback = useRef();
// 记住最新的回调函数
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// 设置 interval
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
}
(如果刚才错过了,这里有 CodeSandbox 演示)
我的 useInterval
Hook 会设置 interval,并在组件卸载时清除。 它其实就是把 setInterval
和 clearInterval
跟组件生命周期绑定在一起。
你可以随意复制到你的项目里,或者发布到 npm。
如果你不关心原理,看到这里就可以停下了!接下来的内容是为那些想深入理解 React Hooks 的朋友准备的。
等等,什么情况?!🤔
我知道你在想什么:
Dan,这代码看起来完全不合理。什么“只是 JavaScript”?承认吧,React 用 Hooks 已经玩脱了!
我一开始也这么想,但后来改变了主意,现在我也想让你改变想法。 在解释为什么这段代码合理之前,我想先展示一下它能做什么。
为什么 useInterval()
是更好的 API
回顾一下,我的 useInterval
Hook 接收一个函数和一个延迟参数:
useInterval(() => {
// ...
}, 1000);
这看起来很像 setInterval
:
setInterval(() => {
// ...
}, 1000);
那为什么不直接用 setInterval
呢?
一开始可能不明显,但 setInterval
和我的 useInterval
Hook 最大的区别在于:它的参数是“动态的”。
我用一个具体例子来说明。
假设我们想让 interval 的延迟可调:
虽然你未必真的用 输入框 控制延迟,但动态调整延迟很有用——比如用户切换到其他标签页时,减少 AJAX 轮询频率。
那如果用类组件和 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} />
</>
);
}
}
其实也不算太复杂!
那用 Hook 怎么写?
🥁🥁🥁function Counter() {
let [count, setCount] = useState(0);
let [delay, setDelay] = useState(1000);
useInterval(() => {
// 你的自定义逻辑
setCount(count + 1);
}, delay);
function handleDelayChange(e) {
setDelay(Number(e.target.value));
}
return (
<>
<h1>{count}</h1>
<input value={delay} onChange={handleDelayChange} />
</>
);
}
没错,就这么简单。
和类组件版本不同,useInterval
Hook 例子里“升级”为动态延迟时,几乎没有复杂度提升:
// 固定延迟
useInterval(() => {
setCount(count + 1);
}, 1000);
// 可调延迟
useInterval(() => {
setCount(count + 1);
}, delay);
当 useInterval
Hook 发现延迟变了,会自动重新设置 interval。
我不需要手动写代码去 设置 和 清除 interval,只需要声明一个带特定延迟的 interval,useInterval
Hook 就会帮我搞定。
如果我想临时暂停 interval,也可以用 state 实现:
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>;
}
现在我想加个 interval,每秒自增。因为这是个需要清理的副作用,所以我用 useEffect()
并返回清理函数:
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <h1>{count}</h1>;
}
看起来很简单?其实基本能用。
但这段代码有个奇怪的行为。
React 默认在每次渲染后都会重新执行 effect。这是有意为之,可以避免一大类 bug,这些 bug 在类组件里很常见。
通常这样挺好,因为很多订阅类 API 可以随时移除旧监听器、添加新监听器。但 setInterval
不是这样。当我们执行 clearInterval
和 setInterval
时,时间点会发生偏移。如果我们频繁重新渲染和重新执行 effect,interval 甚至来不及触发!
我们可以通过更短的 interval 频繁重新渲染组件,看到这个 bug:
setInterval(() => {
// 重新渲染并重新执行 Counter 的 effect
// 导致还没等 interval 触发就被 clearInterval() 了
ReactDOM.render(<Counter />, rootElement);
}, 100);
(在线演示)
第二次尝试
你可能知道 useEffect()
支持“跳过” effect 的重新执行。你可以传一个依赖数组作为第二个参数,只有数组里的内容变化时,effect 才会重新执行:
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
如果我们只想在挂载时执行一次、卸载时清理,可以传一个空数组 []
。
但如果你对 JavaScript 闭包不熟,这里很容易犯错。我们现在就要犯这个错!(我们还专门写了个 lint 规则来提前发现这些 bug。)
第一次尝试的问题是 effect 反复执行导致 timer 被过早清除。我们可以试试让它只执行一次:
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
但现在计数器只会变成 1,然后就不动了。(在线演示)
发生了什么?!
问题在于 useEffect
捕获了第一次渲染时的 count
。 它等于 0
。我们没有重新执行 effect,所以 setInterval
里的闭包一直引用的是第一次渲染的 count
,count + 1
永远是 1
。尴尬!
我能听到你咬牙切齿的声音。Hooks 真让人抓狂,对吧?
一种解决办法是把 setCount(count + 1)
换成“更新器”写法,比如 setCount(c => c + 1)
。这样每次都能拿到最新的 state。但如果你还想读取最新的 props,这就没用了。
另一个办法是用 useReducer()
。这样你在 reducer 里能拿到当前 state 和最新 props,而且 dispatch
函数本身不会变,可以在任何闭包里用。不过 useReducer()
现在还不能直接在 reducer 里触发副作用(但你可以返回新 state 触发 effect)。
但为什么会变得这么绕?
不匹配的阻抗
这个词有时会被用来描述类似的情况,Phil Haack 这样解释:
有人说数据库来自火星,对象来自金星。数据库和对象模型天然不匹配。这就像试图把两个磁铁的北极对在一起。
我们这里的“阻抗不匹配”不是数据库和对象之间,而是 React 编程模型和命令式的 setInterval
API 之间。
一个 React 组件可能会挂载很久,经历很多不同状态,但它的渲染结果一次性描述了所有状态。
// 描述每次渲染
return <h1>{count}</h1>
Hooks 让我们把这种声明式思路用在副作用上:
// 描述 interval 的每种状态
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
我们不是设置 interval,而是声明是否设置 interval 以及延迟是多少。Hook 帮我们搞定。一个连续的过程被离散地描述出来。
相比之下,setInterval
并不能描述一个过程——你一旦设置了 interval,除了清除它,什么都不能改。
这就是 React 模型和 setInterval
API 之间的不匹配。
React 组件的 props 和 state 会变。React 会重新渲染它们,并“忘记”上一次渲染的所有结果,之前的内容变得无关紧要。
useEffect()
Hook 也会“忘记”上一次渲染。它会清理上一次的 effect,再设置新的 effect。新的 effect 会捕获最新的 props 和 state。这就是为什么我们第一次尝试在简单场景下能用。
但 setInterval()
不会“忘记”。 它会永远引用旧的 props 和 state,除非你替换它——但你又不能不重置时间的情况下替换!
或者说,真的不能吗?
refs 来拯救!
问题归结为:
- 我们用第一次渲染的
callback1
执行了setInterval(callback1, delay)
。 - 下一次渲染有了
callback2
,它捕获了最新的 props 和 state。 - 但我们不能不重置时间的情况下替换已有的 interval!
那如果我们根本不替换 interval,而是引入一个可变的 savedCallback
变量,始终指向最新的 interval 回调呢?
现在我们就有了解决方案:
- 我们
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
属性的普通对象,在多次渲染间共享。我们可以把最新的 interval 回调保存进去:
function callback() {
// 能读取最新的 props、state 等
setCount(count + 1);
}
// 每次渲染后,把最新的 callback 存进 ref
useEffect(() => {
savedCallback.current = callback;
});
然后我们可以在 interval 里读取并调用它:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
由于传了 []
,effect 永远只执行一次,interval 不会被重置。但有了 savedCallback
ref,我们每次都能读取到最新的 callback,并在 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>;
}
抽出一个 Hook
说实话,上面的代码确实有点让人晕。混合两种完全不同的范式确实烧脑,用可变 ref 也容易写乱。
我觉得 Hooks 提供的原语比类组件更底层——但它们的美妙之处在于可以组合出更好的声明式抽象。
理想情况下,我只想这样写:
function Counter() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>;
}
我把 ref 机制的主体复制到自定义 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) {
设置 interval 时用它:
let id = setInterval(tick, delay);
既然 delay
可能会变,我需要把它加到 interval effect 的依赖里:
useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, delay);
return () => clearInterval(id);
}, [delay]);
等等,我们不是想避免重置 interval effect,所以才传 []
吗?其实不完全是。我们只是想避免在callback 变化时重置。但当 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]);
}
可以!现在我们可以在任何组件里用 useInterval()
,不用再关心实现细节。
彩蛋:暂停 interval
假如我们想通过传 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]);
就这样。这段代码能应对所有情况:延迟变化、暂停、恢复 interval。useEffect()
API 让我们一开始多花点力气描述 setup 和 cleanup,但后续加新场景就很容易了。
彩蛋:有趣的 Demo
这个 useInterval()
Hook 玩起来真的很有趣。当副作用变得声明式后,组合复杂行为就容易多了。
比如,我们可以让一个 interval 的 delay
由另一个 interval 控制:
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>
</>
);
}
总结
Hooks 需要一段时间去适应——尤其是在命令式和声明式代码的边界上。你可以用它们创造出像 React Spring 这样强大的声明式抽象,但有时也确实会让人抓狂。
现在还是 Hooks 的早期阶段,很多模式还需要我们去摸索和比较。如果你习惯了成熟的“最佳实践”,不用急着上 Hooks。还有很多值得尝试和发现的地方。
希望这篇文章能帮你理解用 Hooks 搭配 setInterval()
这类 API 时常见的坑、如何用合适的模式避开它们,以及如何基于它们创造更强大、更具表现力的声明式 API。