使用React Hooks实现倒计时计时器

29

我试图使用React hooks在屏幕上呈现倒计时计时器,但不确定最佳的呈现方式。

我知道应该使用useEffect将当前状态与先前状态进行比较,但我认为我没有正确地执行它。

我需要帮助!

我尝试了几种不同的方式,但都不起作用,比如每当更新时设置状态,但它最终只会疯狂闪烁。



const Timer = ({ seconds }) => {
    const [timeLeft, setTimeLeft] = useState('');

    const now = Date.now();
    const then = now + seconds * 1000;

    const countDown = setInterval(() => {
        const secondsLeft = Math.round((then - Date.now()) / 1000);
        if(secondsLeft <= 0) {
            clearInterval(countDown);
            console.log('done!');
            return;
        }
        displayTimeLeft(secondsLeft);
    }, 1000);

    const displayTimeLeft = seconds => {
        let minutesLeft = Math.floor(seconds/60) ;
        let secondsLeft = seconds % 60;
        minutesLeft = minutesLeft.toString().length === 1 ? "0" + minutesLeft : minutesLeft;
        secondsLeft = secondsLeft.toString().length === 1 ? "0" + secondsLeft : secondsLeft;
        return `${minutesLeft}:${secondsLeft}`;
    }

    useEffect(() => {
        setInterval(() => {
            setTimeLeft(displayTimeLeft(seconds));
        }, 1000);
    }, [seconds])


    return (
        <div><h1>{timeLeft}</h1></div>
    )
}

export default Timer;```

1
钩子和间隔的经典问题。请看 https://overreacted.io/making-setinterval-declarative-with-react-hooks/ - charlietfl
请注意在拆除时也取消间隔。 - charlietfl
5个回答

80
const Timer = ({ seconds }) => {
  // initialize timeLeft with the seconds prop
  const [timeLeft, setTimeLeft] = useState(seconds);

  useEffect(() => {
    // exit early when we reach 0
    if (!timeLeft) return;

    // save intervalId to clear the interval when the
    // component re-renders
    const intervalId = setInterval(() => {
      setTimeLeft(timeLeft - 1);
    }, 1000);

    // clear interval on re-render to avoid memory leaks
    return () => clearInterval(intervalId);
    // add timeLeft as a dependency to re-rerun the effect
    // when we update it
  }, [timeLeft]);

  return (
    <div>
      <h1>{timeLeft}</h1>
    </div>
  );
};

4
你的意思是 setTimeout 吗?这两种方法都是有效的选项,但 setTimeout 触发函数一次,而 setInterval 触发函数每隔 x 段时间。由于 useEffect 的特性,在 timeLeft 改变时,我们需要设置和清除计时器,我猜它不像一个真正的 setInterval,所以我能理解你在这种情况下使用 setTimeout 的观点。 - Asaf Aviv
是的,我是指设置超时时间。谢谢! - Amir Shitrit
@AmirShitrit 是的,我更喜欢使用setTimeout。 - Frank Fang
我最喜欢的是它能够正常工作。 - skilleo
1
可以通过计算 Date.now() - start 的差值来增强 setInterval 的稳定性,而不是每次减去一秒钟。 - danneu
显示剩余2条评论

11

你应该使用setInterval。我想对@Asaf的解决方案进行一些小改进。每次更改值时,您不必重新设置间隔。这样会删除间隔并每次添加新间隔(在这种情况下可能会更好地使用setTimeout)。因此,您可以删除useEffect的依赖项(即[]):

function Countdown({ seconds }) {
  const [timeLeft, setTimeLeft] = useState(seconds);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setTimeLeft((t) => t - 1);
    }, 1000);
    return () => clearInterval(intervalId);
  }, []);

  return <div>{timeLeft}s</div>;
}

工作示例:

倒计时示例

请注意在setter中我们需要使用这个语法(t) => t - 1以便每次都获得最新的值 (参见:https://reactjs.org/docs/hooks-reference.html#functional-updates)。


编辑 (2021年10月22日)

如果您想要使用setInterval并在0处停止计数器,则可以执行以下操作:

function Countdown({ seconds }) {
  const [timeLeft, setTimeLeft] = useState(seconds);
  const intervalRef = useRef(); // Add a ref to store the interval id

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setTimeLeft((t) => t - 1);
    }, 1000);
    return () => clearInterval(intervalRef.current);
  }, []);

  // Add a listener to `timeLeft`
  useEffect(() => {
    if (timeLeft <= 0) {
      clearInterval(intervalRef.current);
    }
  }, [timeLeft]);

  return <div>{timeLeft}s</div>;
}

Countdown example


该时间间隔将无限运行。 - Asaf Aviv
如果你想在0处停止计时器,可以通过卸载组件来停止:{seconds > 0 && <Countdown seconds={seconds} />}。但是确实,根据要求,可能需要进行一些调整。这里如果您更新了props中的“seconds”,它不会更新间隔值。我只是想提供一个有效的setTimeout替代方案,使用setInterval(而不是每次渲染都重置它)。 - Elfayer
这就是问题所在,父组件对 timeLeft 一无所知,每次重新渲染时更新间隔完全没有问题,并且在 timeLeft 达到某个点(如0)时给您执行操作的选项。您可以在状态更新程序回调中执行此操作,但我认为那样很丑陋。 - Asaf Aviv
ESLint有一个规则react-hooks/exhaustive-deps,它强制在数组中添加timeLeft的依赖项。但是,仍然同意不必每次清除间隔。我认为在这种情况下,我将不得不使用setTimeout - Jose
1
这会准确吗?在下一次执行 useEffect 之间不是会有一些微小的时间流逝吗? - Florian Walther

4

这里有另一种使用setTimeout的替代方案

const useCountDown = (start) => {
  const [counter, setCounter] = useState(start);
  useEffect(() => {
    if (counter === 0) {
      return;
    }
    setTimeout(() => {
      setCounter(counter - 1);
    }, 1000);
  }, [counter]);
  return counter;
};

举例

编辑 fragrant-currying-512ky


1
这是我的一个带有“停止”倒计时的钩子版本。 此外,我添加了一个“fps”(每秒帧数),以显示带小数点的倒计时!
import { useEffect, useRef, useState } from 'react'

interface ITimer {
    timer: number
    startTimer: (time: number) => void
    stopTimer: () => void
}

interface IProps {
    start?: number
    fps?: number
}

const useCountDown = ({ start, fps }: IProps): ITimer => {
    const [timer, setTimer] = useState(start || 0)
    const intervalRef = useRef<NodeJS.Timer>()

    const stopTimer = () => {
        if (intervalRef.current) clearInterval(intervalRef.current)
    }

    const startTimer = (time: number) => {
        setTimer(time)
    }

    useEffect(() => {
        if (timer <= 0) return stopTimer()
        intervalRef.current = setInterval(() => {
            setTimer((t) => t - 1 / (fps || 1))
        }, 1000 / (fps || 1))
        return () => {
            if (intervalRef.current) clearInterval(intervalRef.current)
        }
    }, [timer])

    return { timer, startTimer, stopTimer }
}

export default useCountDown

0

这里有一个小组件 - CountdownTimer - 接受一个输入参数 expiresIn,表示剩余时间(以秒为单位)。

我们使用 useState 来定义 minsec,并在屏幕上显示它们,同时我们使用 timeLeft 来跟踪剩余时间。

我们使用 useEffect 来每秒减少 timeLeft 并重新计算 minsec

此外,我们使用 formatTime 来格式化分钟和秒钟,然后再将它们显示在屏幕上。如果分钟和秒钟都等于零,我们就停止倒计时器。

import { useState, useEffect } from 'react';


const CountdownTimer = ({expiresIn}) => {
    const [min, setMin] = useState(0);
    const [sec, setSec] = useState(0);
    const [timeLeft, setTimeLeft] = useState(expiresIn);

    const formatTime = (t) => t < 10 ? '0' + t : t;

    useEffect(() => {
        const interval = setInterval(() => {
            const m = Math.floor(timeLeft / 60);
            const s = timeLeft - m * 60;

            setMin(m);
            setSec(s);
            if (m <= 0 && s <= 0) return () => clearInterval(interval);

            setTimeLeft((t) => t - 1);
          }, 1000);

          return () => clearInterval(interval);
    }, [timeLeft]);

    return (
        <>
            <span>{formatTime(min)}</span> : <span>{formatTime(sec)}</span>
        </>
    );
}

export default CountdownTimer;

我们可以选择传递一个setter setIsTerminated,以便在倒计时完成后触发父组件中的事件。

const CountdownTimer = ({expiresIn, setIsTerminated = null}) => {
    ...

例如,我们可以在分钟和秒数都等于零时触发它:
if (m <= 0 && s <= 0) {
    if (setTerminated) setIsTerminated(true);
    return () => clearInterval(interval);
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接