かもメモ

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

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秒間隔で更新する方が意外とむずくて手間取った

おわり


[参考]

JavaScript 5刻みの数値に変換したい

与えられた数値を 0, 5, 10, 15, … と 5刻みに丸めたい

結論

const get5RoundNumber = (n: number) => Math.round(n * 2 / 10) * 10 / 2;
  1. 5 刻みの数値を得るには 10 刻み 0, 10, 20, 30, ... を 2 で割れば 0, 5, 10, 15, … ととなる
  2. 0, 10, 20, 30,… という数字を得るには、四捨五入で 0, 1, 2, 3,... を作り 10 倍すれば良い
  3. 最終的に 2 で割るので、もとの数値を 2倍しておく
  4. 1の位で四捨五入するには 1の位を小数点第一位にするために、10で割れば良い

文章で書いてもわかりにくいので表にする

5 刻みに丸める

n * 2 / 10 Math.round * 10 / 2 (Result)
0 0.2 0 0 0
1 0.2 0 0 0
2 0.4 0 0 0
3 0.6 1 10 5
4 0.8 1 10 5
5 10 1 10 5
6 1.2 1 10 5
7 1.4 1 10 5
8 1.6 2 20 10
9 1.8 2 20 10
10 2.0 2 20 10

0.5 刻みに丸める

小数点第二位を四捨五入して 0.5 刻みに丸める場合は 10 で割らなければ良い

n * 2 Math.round / 2 (Result)
0.0 0.2 0 0
0.1 0.2 0 0
0.2 0.4 0 0
0.3 0.6 1 0.5
0.4 0.8 1 0.5
0.5 10 1 0.5
0.6 1.2 1 0.5
0.7 1.4 1 0.5
0.8 1.6 2 1.0
0.9 1.8 2 1.0
1.0 2.0 2 1.0

桁の調整

  • 50 刻みなら 100 で割って Math.round し、100 を掛ける。
  • 5 刻みなら 10 で割って Math.round し、10 を掛ける。
  • 0.5 刻みなら 1 で割って Math.round し、10 を掛ける。
  • 0.05 刻みなら 10 を掛けて Math.round し、10 で割る。

桁数 * 10 をした値が、1 以上なら 割って Math.roundし、掛ける、1以下なら 掛けて Math.round し、で割ればよい。

const getAdjustDigits = (n: number) => {
  const digits = Math.floor(n);
  if (digits > 0) {
    return Math.pow(10, digits);
  } else if (digits < 0) {
    // 桁数が整数の場合は正、少数の場合は負の数にして返す
    return -Math.pow(10, Math.abs(digits) - 1);
  } else {
    return 0;
  }
}

const get5RoundNumber = (n: number, digits: number = 1) => {
  // 桁数が 0 のとき小数点第一位まで表示する
  if (digits === 0) {
    return Math.round(n * 2) / 2;
  }

  const x = getAdjustDigits(digits);
  if (x > 0) {
    return Math.round(n * 2 / x) * x / 2;
  }
  
  const j = Math.abs(x);
  return Math.round(n * 2 * j) / j / 2;
}

こんな感じで 5 刻みの数値に変換できそう
5 になりやすいので微妙に調整したほうが良い気もするが、ざっくり要件が満たせてると思う

有効桁数のひとつ下の桁数を四捨五入してから 5刻みに変換する

5桁で丸める前に数字を有効桁数の一つ下で四捨五入すれば良い

const getAdjustDigits = (n: number) => {
  const digits = Math.floor(n);
  if (digits > 0) {
    return Math.pow(10, digits);
  } else if (digits < 0) {
    // 桁数が整数の場合は正、少数の場合は負の数にして返す
    return -Math.pow(10, Math.abs(digits) - 1);
  } else {
    return 0;
  }
}

// 有効桁数の1つ下の桁で四捨五入した数値を返す
const getRoundedNumber = (n: number, digits: number, isInteger: boolean = true) => {
  const d = (isInteger && digits > 1) ? digits / 10 : digits * 10;
  
  if (isInteger) {
    return Math.round(n / d) * d;
  }
  
  return Math.round(n * d) / d;
}

const get5RoundNumber = (n: number, digits: number = 1) => {
  // 桁数が 0 のとき小数点第一位まで表示する
  if (digits === 0) {
    return Math.round(n * 2) / 2;
  }
  
  const x = getAdjustDigits(digits);
  const j = Math.abs(x);
  const roundN = getRoundedNumber(n, j, x > 0);

  if (x > 0) {
    return Math.round(roundN * 2 / x) * x / 2;
  }
  
  return Math.round(roundN * 2 * j) / j / 2;
}

Sample

See the Pen 5 round number by KIKIKI (@kikiki_kiki) on CodePen.

おわり


TypeScript 全てオプショナルなプロパティを引数に取る関数を引数無しで呼び出したい

タイトルのとおりなのだけど、日本語で書くとムズい

type MyFuncArgumens = {
  defaultValue?: number;
  callback?: () => void;
};

const myFunction = ({
  defaultValue = 1;
  callback
}: MyFuncArgumens) => {
  // ...
};

全てオプショナルなプロパティを持つオブジェクトを引数に取る関数 myFunction がある
何もプロパティを渡さない時に引数無しで呼び出したいが、上記の定義では Type Error になる

myFunction();
// -> Expected 1 arguments, but got 0
// -> An argument matching this binding pattern was not provided.

Expected 1 arguments, but got 0 引数の数が合わないエラーになる。
それはそう。

引数自体を partial にしても同様にエラーが発生する

const myFunction = ({
  defaultValue = 1;
  callback
}: MyFuncArgumens | undefined) => {
  // ...
};

myFunction();
// -> Expected 1 arguments, but got 0

引数なしの呼び出しは呼び出された関数の引数が undefined 型として扱われる訳ではない

引数に空オブジェクト {} を渡して myFunction({}) とすれば問題ないが、イケてない。こういう場合は myFunction() で呼び出したい。

関数のデフォルト引数を空オブジェクト {} にすれば OK

シンプルに関数のデフォルト引数そのものを空オブジェクトにすればよい。

type MyFuncArgumens = {
  defaultValue?: number;
  callback?: () => void;
};

const myFunction = ({
  defaultValue = 1;
  callback
}: MyFuncArgumens = {}) => {
  // ...
};

myFunction(); // OK

オブジェクトの各プロパティにデフォルト値を付ける事は当たり前にしてたのに、めちゃくちゃシンプルに引数であるオブジェクト自体にデフォルト引数を付ければ済む事を見落としてた。

また忘れそうだからメモとして残しておく

おわり ₍ᐢ. ̫.ᐢ₎