React 实现倒计时功能

5,150 阅读3分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

背景

在业务中我们经常会用到倒计时功能,比如获取验证码:倒计时 60 秒,每秒 time-1,其实很简单。

初步实现

import { useState, useEffect } from "react";

interface IProps {
  mss: number;
}

export default function CountDown(props: IProps) {
  const { mss } = props;

  const [time, setTime] = useState(mss);

  useEffect(() => {
    const tick = setInterval(() => {
      setTime(time - 1);
    }, 1000);

    console.log("tick", tick);

    return () => clearInterval(tick);
  });

  return (
    <p>{time.toString().padStart(2, "0")}</p>
  );
}

上面代码可以执行,并且结果也符合预期,但是有个问题:为了保证 useEffect 每次渲染都会执行,我没有添加依赖项;导致每次 useEffect 执行都会生成一个新的计时器。打印 tick 结果如下。

截屏2022-06-01 下午10.51.39.png

定时器及时没有清理,会造成很大的性能浪费。如何保证回调函数每次都会正确执行,且不会产生新的计时器?

  • 首先,不产生新的计时器,只在页面初始化时触发 setInterval 就好了,使用 useEffect 可以做到。
  • 在每次执行 setInterval 回调时,需要保证能获取到正确的执行上下文,即变量 time 的值。这就要用到 useRef。

使用 ref

我们先来看代码实现,每次渲染时,将回调函数缓存在 ref 中,函数会保持对 time 的最新引用

import { useState, useEffect, useRef } from "react";

interface IProps {
  mss: number;
}

type Fnc = () => void;
const noop = () => {};

export default function CountDown(props: IProps) {
  const { mss } = props;
  const [time, setTime] = useState(mss);

  // 使用 ref 来存储回调函数
  const tickRef = useRef<Fnc>(noop);

  // 回调函数,携带变量 time 的上下文
  const tick = () => {
    if (time > 0) {
      setTime(time - 1);
    }
  };
	
  // 每次渲染,将 tick 赋值给 ref,以保留上下文
  useEffect(() => {
    tickRef.current = tick;
  });

  useEffect(() => {
    // 在初始化后,setInterval callback 都会获得 ref 的最新值
    const timer = setInterval(() => tickRef.current(), 1000);
    console.log("tick", timer);

    return () => clearInterval(timer);
  }, []);

  return (
    <p>{time.toString().padStart(2, "0")}</p>
  );
}

tick 的打印结果,只有两次:

截屏2022-06-01 下午11.10.25.png

useRef 的原理

useRef 的功能是生成一个对象,其结构是 {current: value} ,对象一旦初始化,就不会随着组件更新而改变。

ref 相当于函数组件的一个全局变量,类似 class 组件的实例属性,但是我们可以通过 ref.current = newValue 对它赋值。

useRef 最常用的场景是设置组件的 ref,如下,div 上的 ref 属性将触发设置 domRef.current 指向 dom 对象

const domRef = useRef(null);
return <div ref={domRef}></div>

当然,我们也可以灵活应用,把 useRef 作为生成组件变量的方法:

  • 在组件初始化时,useRef 会生成对象 {current: value},然后缓存并返回该对象;
  • 当组件更新时,useRef 会获取缓存对象并返回,我们总是可以获得最新的对象,从而避开 hooks 的闭包陷阱

封装自定义 hooks

import { useEffect, useRef, useState } from "react";

interface IProps {
  mss: number;
}
type Fnc = () => void;
const noop = () => {};

const useCountDown = (props: Partial<IProps>) => {
  const { mss } = props;
  const [time, setTime] = useState(mss || 0);
  const tickRef = useRef<Fnc>(noop);

  const tick = () => {
    if (time > 0) {
      setTime(time - 1);
    }
  };

  useEffect(() => {
    tickRef.current = tick;
  });

  useEffect(() => {
    const timerId = setInterval(() => tickRef.current(), 1000);
    console.log("timerId", timerId);

    return () => clearInterval(timerId);
  }, []);

  return [time];
};

export default useCountDown;
import useCountDown from "./hooks/useCountDown";

interface IProps {
  mss: number;
}

export default function CountDown(props: IProps) {
  const { mss } = props;
  const [time] = useCountDown({ mss });

  return (
    <p>{time.toString().padStart(2, "0")}</p>
  );
}

参考链接