かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

React requestAnimationFrameでカウントダウンタイマーを作った

LP とかでよく出てくるやつ。個人的にはあまり好きじゃないけど、作るケースが割とあるので。

Window.requestAnimationFrame

The frequency of calls to the callback function will generally match the display refresh rate. The most common refresh rate is 60hz, (60 cycles/frames per second), though 75hz, 120hz, and 144hz are also widely used. requestAnimationFrame() calls are paused in most browsers when running in background tabs or hidden <iframe>s, in order to improve performance and battery life.
Warning: Be sure always to use the first argument (or some other method for getting the current time) to calculate how much the animation will progress in a frame — otherwise, the animation will run faster on high refresh-rate screens. For ways to do that, see the examples below.
cf. Window: requestAnimationFrame() method - Web APIs | MDN

Window.requestAnimationFrame は画面のリフレッシュレートのタイミングで実行されるので setIntervalsetTimeout と違いレンダリングを邪魔しないらしく、バックグランドタブや非表示のタブでは実行が一時停止されるので常に実行しておく処理ならこちらのほうがパフォーマンスが良い
リフレッシュレートのタイミングで実行されるので、実行される間隔はディスプレイ依存となる。
リフレッシュレートが60hz なら 1秒に60回、120hzなら 1秒に120回実行される。一定の間隔で実行させるにはアニメーションの経過時間の差を用いるなどの工夫が必要

React で requestAnimationFrame を使ったタイマーを作る

useEffect でループ処理を呼び出してタイマーの状態を更新。コンポーネントがアンマウントされた際に cancelAnimationFrame でループ処理を止めれば良さそう

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

const App: FC = () => {
  const [count, setCount] = useState<number>(0);
  // requrstID を保持させる
  const reqIdRef = useRef<number>(0);

  const loopStart = useCallback(() => {
    let start = 0;
    
    const loop = (timestamp?: number) => {
     const now = Math.round(timestamp);
      if(start === 0 && now) {
        start = now;
      }
      
      const progress = (now - start) % 1000;

      if (progress === 0) {
        setCount((count) => count += 1);
      }

      reqIdRef.current = requestAnimationFrame(loop);
    }
    return loop;
  }, []);

  const reset = useCallback(() => {
    cancelAnimationFrame(reqIdRef.current);
  }, []);

  useEffect(() => {
    loopStart()();
    
    return () => reset();
  }, []);

  return (
    <div>{count}</div>
  );
}

Sample

See the Pen React Counter with requestAnimationFrame by KIKIKI (@kikiki_kiki) on CodePen.

カウンドダウンタイマーを作成する

方針

  1. カウントダウンするターゲットの日時を与える
  2. ループ内でターゲットの日時と現在の時間のの差を求める
  3. 差 を 日, 時間, 分 に変換する
  4. 時間の差が 0 以下のときは 00日00時00分00秒 とする
import { useState, useEffect, useRef } from 'react';

const TARGET_DATE = '2024-08-01';

const App = () => {
  const targetDate = new Date(TARGET_DATE);
  const targetTimestamp =  targetDate.getTime();
  // Invalid Date
  if ( isNaN(targetTimestamp) ) {
    return null;
  }
  return <CountDownTimer target={targetTimestamp} />
}

type Days = number;
type Hours = number;
type Minutes = number;
type Seconds = number;
type CounterState = [Days, Hours, Minutes, Seconds];
const LABELS = ['日', '時', '分', '秒'] as const;

type CountDownTimerProps = {
  target: number;
}

const CountDownTimer: FC<CountDownTimerProps> = ({ target }) => {
  const [time, setTime] = useState<ReturnType<getTimes>>([0, 0, 0, 0]);
  const reqIdRef = useRef<number>(0);

  const loop = useCallback(() => {
    const now = Date.now();
    const distance = target - now;
    setTime(getTimes(distance));
    
    if (distance < 0) {
      reset();
      return;
    }
    
    reqIdRef.current = requestAnimationFrame(loop);
  }, [target]);

  const reset = useCallback(() => {
    cancelAnimationFrame(reqIdRef.current);
  }, []);
  
  useEffect(() => {
    loop();
    
    return () => reset();
  }, [target]);

  return (
    <div className="countdown">
      {time.map((n, i) => (
        <div key={i}>
          <span className="counter">{n.toString().padStart(2, '0')}</span>
          <span className="label">{LABELS[i]}</span>
        </div>
      ))}
    </div>
  );
};

// 差の時間から残りの 日, 時間, 分, 秒 を計算して返す
const getTimes = (ms: number): CounterState => {
  if (ms <= 0) {
    return [0, 0, 0, 0];
  }
  
  const d = Math.floor(ms / (1000 * 60 * 60 * 24));
  const h = Math.floor((ms / (1000 * 60 * 60)) % 24);
  const m = Math.floor((ms / (1000 * 60)) % 60);
  const s = Math.floor((ms / 1000) % 60);
  
  return [d, h, m, s];
}

Sample

See the Pen React Counter with requestAnimationFrame by KIKIKI (@kikiki_kiki) on CodePen.

サンプル作ろうとしたら 1秒間隔で更新する方が意外とむずくて手間取った

おわり


[参考]