一个倒计时组件引发的问题

3,715 阅读3分钟

起因

简简单单,我想实现一个倒计时组件,如下的功能。对比学习 Class 组件和 React Hook 实现方式的区别,不做不知道,一做发现了大玄机。且向下看。

React Hook

关于 React Hook 快速入门,请看这篇博客:juejin.cn/post/692715…

递增的变量

如果想实现一个 1 秒增加 1 的变量,使用 React Hook,我们很容易写出下面有问题的代码:

const Demo = ()=>{
    const [count, setCount] = useState(0);
    console.log("init...");
    useEffect(()=>{
        const timer = setInterval(() => {
            console.log("interval...");
            setCount(count+1);
        }, 1000);
        return ()=>{
            console.log("clear...");
            clearTimeout(timer);
        }
    }, []);
    return (
        <p>{count}</p>
    )
}

useEffect(()=>{}, []) 表示的是组件加载时会执行的逻辑,[] 空数组表示 useEffect 里面的代码逻辑跟组件中的变量无关。

为了说明代码的执行顺序,添加了 console.log 的提示信息,会发现 init 只会输出一次,interval 每秒输出一次。

事实上,这么写是有问题的。setInterval 是会每秒钟执行一次,但是呢? count 的值永远是第一次获取的值也就是 0,也就是每秒中 setInterval 中执行的逻辑是 setCount(1)。所以页面上 count 的值从 0 变成 1 就没有变过了。在 React Hook (juejin.cn/post/692715…) 快速入门也提到了,使用 setState 的变量是快照 snap 时的值。

另一方面,使用 useEffect 有一个重要原则就是对于依赖的变量要 诚实,可以看到 setInterval的逻辑中明明依赖了 count 的值,也就是这次更新的 count 要依赖上一次 count 的值,+1 完成。想到这一点,我们可以优化一下:

const Demo = ()=>{
    const [count, setCount] = useState(0);
    console.log("init...");
    useEffect(()=>{
        const timer = setInterval(() => {
            console.log("interval...");
            setCount(count+1);
        }, 1000);
        return ()=>{
            console.log("clear...");
            clearTimeout(timer);
        }
    }, [count]);
    return (
        <p>{count}</p>
    )
}

这样效果是对的,但是性能不好。每当 count 更改了, useEffect 就会渲染一次,定时器也会不停的被新增与移除。

控制台在不停的 init、clear、interval 循环输出。站在神坛的 React Hook 本质也是一个函数而已。当 setState 的变量 count 变化的时候,Demo 才会重新执行一遍。那么执行两次 Demo 函数是两个独立的函数,没有什么牵扯。

这篇博客 www.fly63.com/article/det… 介绍了 4 种优雅的解决方案。这里详细解释一下用 useRef 的实现方式:

function Counter() {
    let [count, setCount] = useState(0);
    const myRef = useRef(null);
    myRef.current = () => {
      setCount(count + 1);
    };
    useEffect(() => {
      let id = setInterval(()=>{
        myRef.current();
      }, 1000);
      return () => clearInterval(id);
    }, []);
    return <h1>{count}</h1>;
  }

在 myRef.current(); 的外层要包一层函数,为什么呢?道理同下:

let cur = ()=>{
    console.log(1);
}
setInterval(cur, 1000);
cur = ()=>{
    console.log(2);
}

循环输出 1, 而不是最新的 2

let cur = ()=>{
    console.log(1);
}

setInterval(()=>{
    cur();
}, 1000);

cur = ()=>{
    console.log(2);
}

这样循环输出 2.

倒计时

很抱歉,倒计时组件最终版本并没有用到上面的技巧使用 myRef.current 去获取最新的数据。而是根据当前时间跟倒计时的时间做比较,这样做可以保证精度,我们都知道 setInterval setTimeout 是宏任务,并不能保证函数执行的精度,所以将时间计算放在了函数中,函数执行的时候获取当前的时间然后做差,保证了倒计时的可靠。

let timer = null;
const CountDownHook = (props) => {
  const finishDate = props.finishDate || "2021/02/17 23:47:00";
  const [second, setSecond] = useState(0);
  const [minute, setMinute] = useState(0);
  const [hour, setHour] = useState(0);
  const [day, setDay] = useState(0);

  const calulate = ()=>{
      const seconds = new Date(finishDate).getTime() - new Date().getTime();
      if(seconds<=0){
          clearInterval(timer);
          return;
      }
      const days = Math.floor(seconds / (24 * 60 * 60 * 1000));
      const hours = Math.floor((seconds - days * 24 * 60 * 60 * 1000) / (60 * 60 * 1000));
      const minutes = Math.floor((seconds - days * 24 * 60 * 60 * 1000 - hours * 60 * 60 * 1000) / (60 * 1000));
      const lastSecond = Math.floor((seconds - days * 24 * 60 * 60 * 1000 - hours * 60 * 60 * 1000 - minutes * 60 * 1000) / 1000);
      setSecond(lastSecond);
      setMinute(minutes);
      setHour(hours);
      setDay(days);
  }

  useEffect(() => {
      // 初始化
      calulate();
      // 每秒执行
      timer = setInterval(calulate, 1000)
      return () => {
          clearTimeout(timer);
      }
  }, [])
  return ( <p style = {{marginLeft: "100px",marginTop: "100px"}} > 距离 {finishDate}倒计时: {day} 天 {hour} 小时 {minute} 分钟 {second} 秒 </p>)
}

Class 版本

class CountDownClass extends React.Component {
  constructor(props) {
    super(props);
    this.timer = null;
    this.state = {
      day: 0,
      hour: 0,
      minute: 0,
      second: 0
    }
  }

  changeSecond = () => {
    const finishDate = this.props.finishDate || "2021/02/17 20:00:00";
    const seconds = new Date(finishDate).getTime() - new Date().getTime();
    if(seconds<=0){
      clearInterval(this.timer);
      return;
    }
    const days = Math.floor(seconds / (24 * 60 * 60 * 1000));
    const hours = Math.floor((seconds - days * 24 * 60 * 60 * 1000) / (60 * 60 * 1000));
    const minutes = Math.floor((seconds - days * 24 * 60 * 60 * 1000 - hours * 60 * 60 * 1000) / (60 * 1000));
    const lastSecond = Math.floor((seconds - days * 24 * 60 * 60 * 1000 - hours * 60 * 60 * 1000 - minutes * 60 * 1000) / 1000);
    this.setState({
      day: days,
      hour: hours,
      minute: minutes,
      second: lastSecond
    });
  }

  componentDidMount() {
    // 初始化
    this.changeSecond();
    // 每秒执行
    this.timer = setInterval(this.changeSecond, 1000);
  }

  componentWillUnmount(){
    clearInterval(this.timer);
  }

  render() {
    const finishDate = this.props.finishDate || "2021/02/17 23:00:00";
    const {day, hour, minute, second} = this.state;
    return (
        <p style = {{marginLeft: "100px",marginTop: "10px"}} > 距离 {finishDate}倒计时: {day} 天 {hour} 小时 {minute} 分钟 {second} 秒 </p>
    )
  }
}