所以在 class 里你要怎么用 setInterval 做到这一点呢?我会这么做:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | 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 有可能没有机会被执行!
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 | 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 时清理它,我们可以传空 [] 的依赖数组。
01 02 03 04 05 06 07 08 09 10 11 | 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。)
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 | 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 上:
1 2 3 4 | // 描述每个间隔状态
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)」,所以不可以是一个常规变量,我们想要一个类似实例的字段。
1 2 | const savedCallback = useRef();
// { current: null }
|
useRef() 返回一个有带有 current 可变属性的普通对象在 renders 间共享,我们可以保存新的 interval 回掉给它:
1 2 3 4 5 6 7 8 9 | function callback() {
// 可以读到新 props,state等。
setCount(count + 1);
}
// 每次渲染后,保存新的回调到我们的 ref 里。
useEffect(() => {
savedCallback.current = callback;
});
|
之后我们便可以从我们的 interval 中读取和调用它:
1 2 3 4 5 6 7 8 | useEffect(() => {
function tick() {
savedCallback.current();
}
let id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
|
感谢 [],不重执行我们的 effect,interval 就不会被重置。同时,感谢 savedCallback ref,让我们可以一直在新渲染之后读取到回调,并在 interval tick 里调用它。
提取一个 Hook
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 | 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 是写死的,我想把它变成一个参数:
01 02 03 04 05 06 07 08 09 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | 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:
1 2 3 4 5 6 | const [delay, setDelay] = useState(1000);
const [isRunning, setIsRunning] = useState(true);
useInterval(() => {
setCount(count + 1);
}, isRunning ? delay : null);
|
如何实现这个?答案时:不创建 interval。
01 02 03 04 05 06 07 08 09 10 | useEffect(() => {
function tick() {
savedCallback.current();
}
if (delay !== null) {
let id = setInterval(tick, delay);
return () => clearInterval(id);
}
}, [delay]);
|