金数据技术博客 · №3
React Hooks 中使用 setInterval
一、困惑
在 React Hooks 中我们使用 setInterval 有时会偏离自己想要的效果,给我们来带一些困惑。
然而我们发现这并不是 React Hooks 的问题,而是 React 编程模式和 setInterval 不匹配造成的。React Hooks 函数式的方式更贴近 React 编程模型,而 setInterval 更符合命令式编程。
那么我们就来深入分析一下如何在 React Hooks 中使用 setInterval。
二、尝试
首先我们从一个只渲染初始状态的简单例子开始,代码如下:
function Counter() {
const [count, setCount] = useState(0);
return <p>{count}</p>;
}接下我们添加一个每秒增加的 interval,它是一个需要清理副作用的,所以我将用到 useEffect() 并返回清理函数,代码如下:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <p>{count}</p>;
}这样工作看起来很简单对吧,似乎也没什么问题。
默认情况下,React 会在每次渲染后重执行 effects,这是有目的的,这有助于避免 React class 组件的某种 bugs。
这通常是好的,因为需要许多订阅 API 可以随时顺手移除老的监听者和加个新的。但是,setInterval 和它们不一样。当我们执行 clearInterval 和 setInterval 时,它们会进入时间队列里,如果我们频繁重渲染和重执行 effects,interval 有可能没有机会被执行!
例如我们可以通过以更短间隔重渲染我们的组件,来发现这个 bug,代码如下:
export default function App() {
const [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 100);
return () => clearInterval(id);
});
return (
<div className="App">
{count}
<h1>Hello CodeSandbox</h1>
<Counter />
</div>
);
}三、继续尝试
我们都知道 useEffect() 允许我们选择性地进行重执行 effects,我们可以设定一个依赖数组作为第二个参数,React 只会在数组里的某个发生变化时重运行。
我们的问题是重运行 effects 时使得 timer 过早被清除,我们可以尝试不重运行去修复它们:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}但是,我们发现计时器更新到 1 就不动了。(代码地址)
这中间发生了什么❓ 🤔
这个问题在于,useEffect 在第一次渲染时获取值为 0 的 count,我们不再重执行 effect,所以 setInterval 一直引用第一次渲染时的闭包 count,以至于 count + 1 一直是 1。
修复它的方式就是使用函数式更新,就像 setCount(c => c + 1) 这样替换 setCount(count + 1),这样可以读到新 state 变量。但这个无法帮助你获取到新的 props。
四、React Hooks 和 setInterval 的差异
我们总想是在 React 编程模式和命令式 setInterval 之间寻找平衡点,想让它们融合在一起工作。但其实它们存在一些模式不匹配。
一个 React 组件可能在 mounted 之前流经许多不同的 state,但它的渲染结果将一次性全部描述出来。
// 描述每次渲染
return <p>{count}</p>Hooks 使我们把相同的声明方法用在 effects 上:
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 100);
return () => clearInterval(id);
});相反,setInterval 没有及时地描述过程,一旦设定了 interval,除了清除它,你无法对它做任何改变。
这就是 React 编程模式和 setInterval 之间的不匹配。
差异的体现
React 组件中的 props 和 state 是可以改变的, React 会重渲染它们且丢弃任何关于上一次渲染的结果,它们之间不再有相关性。
useEffect() Hook 也丢弃上一次渲染结果,它会清除上一次 effect 再建立下一个 effect,下一个 effect 产生新的 props 和 state。
但 setInterval 不会丢弃。 它会一直引用老的 props 和 state 直到你把它换掉。(不重置时间你是无法做到的)
五、Refs 方案
首先我们先总结一下问题:
- 我们在第一次渲染时执行带
callback1的setInterval(callback1, delay)。 - 我们在下一次渲染时得到携带新的
props和state的callback2。 - 我们无法在不重置时间的情况下替换掉已经存在的 interval。
那么如果我们根本不替换 interval,而是引入一个指向新 interval 回调的可变 savedCallback 会怎么样?现在我们来看看这个方案:
- 我们调用
setInterval(fn, delay),其中fn调用savedCallback。 - 第一次渲染后将
savedCallback设为callback1。 - 下一次渲染后将
savedCallback设为callback2。 - 重复上面的步骤,完成。
那么这个可变的 savedCallback 需要在重新渲染时可持续,所以不可以是一个常规变量,我们想要一个类似对象的变量。
这时我们会想到使用 useRef() 这个 Hooks。
const savedCallback = useRef();useRef 返回一个可变的 ref 普通对象在 render 间共享。我们可以保存新的 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 <p>{count}</p>;
}到这里我们就让 React Hooks 和 setInterval 完美的运行在了一起。
六、自定义 useInterval
上述代码如果直接使用会比较麻烦,于是我们就提取一个 custom Hook,例如 useInterval,代码如下:
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]);
}我们还可以添加一个 delay 参数,来调整时间间隔,当 delay 改变时,我们重新生成一个 timer。
我们只需要在组件中这样使用即可:代码地址
function App() {
const [count, setCount] = useState(0);
useInterval(() => {
setCount(count + 1);
}, 1000);
return (
<div className="App">
<p>{count}</p>
</div>
);
}添加暂停功能
假设我们希望能够通过传递 null 作为 delay 来暂停我们的 interval,如下:
const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(
() => {
setCount(count + 1);
},
isRunning ? delay : null
);然后我们修改一下 useInterval 的代码,如下:
useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);到此我们的 custom Hook 也编写完成。代码地址