金数据技术博客 · №3

React Hooks 中使用 setInterval

· silverWolf818 · frontend / React / translation

一、困惑

在 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 和它们不一样。当我们执行 clearIntervalsetInterval 时,它们会进入时间队列里,如果我们频繁重渲染和重执行 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 组件中的 propsstate 是可以改变的, React 会重渲染它们且丢弃任何关于上一次渲染的结果,它们之间不再有相关性。

useEffect() Hook 也丢弃上一次渲染结果,它会清除上一次 effect 再建立下一个 effect,下一个 effect 产生新的 propsstate

setInterval 不会丢弃。 它会一直引用老的 propsstate 直到你把它换掉。(不重置时间你是无法做到的)

五、Refs 方案

首先我们先总结一下问题:

那么如果我们根本不替换 interval,而是引入一个指向新 interval 回调的可变 savedCallback 会怎么样?现在我们来看看这个方案:

那么这个可变的 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 也编写完成。代码地址


原文链接:Making setInterval Declarative with React Hooks