overreactedby Dan Abramov

用 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>;
}

CodeSandbox 在线演示

这里的 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,并在组件卸载时清除。 它其实就是把 setIntervalclearInterval 跟组件生命周期绑定在一起。

你可以随意复制到你的项目里,或者发布到 npm。

如果你不关心原理,看到这里就可以停下了!接下来的内容是为那些想深入理解 React Hooks 的朋友准备的。


等等,什么情况?!🤔

我知道你在想什么:

Dan,这代码看起来完全不合理。什么“只是 JavaScript”?承认吧,React 用 Hooks 已经玩脱了!

我一开始也这么想,但后来改变了主意,现在我也想让你改变想法。 在解释为什么这段代码合理之前,我想先展示一下它能做什么。


为什么 useInterval() 是更好的 API

回顾一下,我的 useInterval Hook 接收一个函数和一个延迟参数:

  useInterval(() => {
    // ...
  }, 1000);

这看起来很像 setInterval

  setInterval(() => {
    // ...
  }, 1000);

那为什么不直接用 setInterval 呢?

一开始可能不明显,但 setInterval 和我的 useInterval Hook 最大的区别在于:它的参数是“动态的”

我用一个具体例子来说明。


假设我们想让 interval 的延迟可调:

带输入框可调 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} />
      </>
    );
  }
}

CodeSandbox 在线演示

其实也不算太复杂!

那用 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} />
    </>
  );
}

CodeSandbox 在线演示

没错,就这么简单

和类组件版本不同,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>;
}

CodeSandbox 演示

看起来很简单?其实基本能用。

但这段代码有个奇怪的行为。

React 默认在每次渲染后都会重新执行 effect。这是有意为之,可以避免一大类 bug,这些 bug 在类组件里很常见。

通常这样挺好,因为很多订阅类 API 可以随时移除旧监听器、添加新监听器。但 setInterval 不是这样。当我们执行 clearIntervalsetInterval 时,时间点会发生偏移。如果我们频繁重新渲染和重新执行 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 里的闭包一直引用的是第一次渲染的 countcount + 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>;
}

CodeSandbox 在线演示


抽出一个 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]);
}

CodeSandbox 在线演示

可以!现在我们可以在任何组件里用 useInterval(),不用再关心实现细节。

彩蛋:暂停 interval

假如我们想通过传 nulldelay,来暂停 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 在线演示

就这样。这段代码能应对所有情况:延迟变化、暂停、恢复 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>
    </>
  );
}

CodeSandbox 在线演示

总结

Hooks 需要一段时间去适应——尤其是在命令式和声明式代码的边界上。你可以用它们创造出像 React Spring 这样强大的声明式抽象,但有时也确实会让人抓狂。

现在还是 Hooks 的早期阶段,很多模式还需要我们去摸索和比较。如果你习惯了成熟的“最佳实践”,不用急着上 Hooks。还有很多值得尝试和发现的地方。

希望这篇文章能帮你理解用 Hooks 搭配 setInterval() 这类 API 时常见的坑、如何用合适的模式避开它们,以及如何基于它们创造更强大、更具表现力的声明式 API。

Pay what you like

Edit on GitHub