所以在 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} />
</>
);
}
}
Hook 版本
因为需要许多订阅 API 可以随时顺手移除老的监听者和加个新的。但是,setInterval 和它们不一样。当我们执行 clearInterval 和 setInterval 时,它们会进入时间队列里,如果我们频繁重渲染和重执行 effects,interval 有可能没有机会被执行!
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
});
return <h1>{count}</h1>;
}
const rootElement = document.getElementById("root");
// Second interval to demonstrate the issue.
// Fast updates from it cause the Counter's
// interval to constantly reset and never fire.
setInterval(() => {
ReactDOM.render(<Counter />, rootElement);
}, 100);
当我们 只 想在 mount 时执行 effect 和 unmount 时清理它,我们可以传空 [] 的依赖数组。
function Counter() {
let [count, setCount] = useState(0);
useEffect(() => {
let id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
但是,现在我们的计时器更新到 1 就不动了
问题在于,useEffect 在第一次渲染时获取值为 0 的 count,我们不再重执行 effect,所以 setInterval 一直引用第一次渲染时的闭包 count,以至于 count + 1 一直是 1。哎呀呀!
- 修复它的一种方法是用像 setCount(c => c + 1) 这样的 「updater」替换 setCount(count + 1),这样可以读到新 state 变量。但这个无法帮助你获取到新的 props。
- 另一个方法是用 useReducer()。这种方法为你提供了更大的灵活性。在 reducer 中,你可以访问到当前 state 和新的 props。dispatch 方法本身永远不会改变,所以你可以从任何闭包中将数据放入其中。useReducer() 有个约束是你不可以用它执行副作用。(但是,你可以返回新状态 —— 触发一些 effect。)
function Counter() {
const [count, dispatch] = useReducer((state, action) => {
if (action === 'inc') {
return state + 1;
}
}, 0);
useEffect(() => {
let id = setInterval(() => {
dispatch('inc');
}, 1000);
return () => clearInterval(id);
}, []);
return <h1>{count}</h1>;
}
const rootElement = document.getElementById("root");
ReactDOM.render(<Counter />, rootElement);
阻抗不匹配
我们的「阻抗匹配」不在数据库和对象之间,它在 React 编程模型和命令式 setInterval API 之间。
一个 React 组件可能在 mounted 之前流经许多不同的 state,但它的渲染结果将一次性全部描述出来。
Hooks 使我们把相同的声明方法用在 effects 上:
// 描述每个间隔状态
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
我们不设置 interval,但指定它是否设置延迟或延迟多少,我们的 Hooks 做到了,用离散术语描述连续过程
相反,setInterval 没有及时地描述过程 —— 一旦设定了 interval,除了清除它,你无法对它做任何改变。
这就是 React 模型和 setInterval API 之间的不匹配。
Refs 出马
- 我们在第一次渲染时执行带 callback1 的 setInterval(callback1, delay)。
- 我们在下一次渲染时得到携带新的 props 和 state 的 callbaxk2。
- 我们无法在不重置时间的情况下替换掉已经存在的 interval。
那么如果我们根本不替换 interval,而是引入一个指向新 interval 回调的可变 savedCallback 会怎么样? 现在我们来看看这个方案:
- 我们调用 setInterval(fn, delay),其中 fn 调用 savedCallback。
- 第一次渲染后将 savedCallback 设为 callback1。
- 下一次渲染后将 savedCallback 设为 callback2。
这个可变的 savedCallback 需要在重新渲染时「可持续(persist)」,所以不可以是一个常规变量,我们想要一个类似实例的字段。
const savedCallback = useRef();
// { current: null }
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 里调用它。
提取一个 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 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]);
}
等等,我们不是要避免重置 interval effect,并专门通过 [] 来避免它吗?不完全是,我们只想在回调改变时避免重置它,但当 delay 改变时,我们想要重启 timer!
有效!我们现在可以不用想太多 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]);