公開したタイマーが使えなかったので原因究明

2024/12/11に公開

はじめに

Reactでポモドーロタイマーを作ってGitHub Pagesで公開しました
https://haru-036.github.io/pomotime/

基本的な機能は大丈夫だし できた! と思って、実際にスタートしてから作業を始めました
すると、25分なはずなのに30分経っても、1時間経っても通知が来ません

カウントダウンが全然進んでないんですよ

原因:ブラウザ

原因はReactやGitHub Pagesではなく、ブラウザだとわかりました

ブラウザはユーザーが別のタブや別のアプリに切り替えたりすると、非アクティブなタブやウィンドウでのJavaScriptの実行頻度を自動的に低下させることがあります
このためバックグラウンドで動作させようとしてもタイマーが期待通りに進まないということです

使用技術

  • React
  • TypeScript
  • Vite
  • TailwindCSS

元々の実装方法

まずこれを作ろうとしたきっかけがReactの基礎固めをしたいということでした
そのため、最初は色々と調べました

すると、setIntervalを使っている記事がいくつも出てきたので、なんとなくは知ってるやつだしこれで作れるのかーと実装していきました

setIntervalについてはこちらをご覧ください
https://developer.mozilla.org/ja/docs/Web/API/Window/setInterval

スタートボタンを押した時の処理

(無駄なステートが多いのは今回は見逃してください…)

  const intervalID = useRef<number>();

  const startTime = useCallback((): void => {
    intervalID.current = setInterval(() => tick(), 1000);
    setIsStart(true);
    setIsStop(false);
    setIsTimeUp(false);
    setIsReset(false);
  }, []);

ここでは、1秒(1000ms)ごとにtickという関数を実行していますね
以下のコードがtick関数ですが、これは条件ごとにtimeLimitを更新するための関数です

  const tick = useCallback(() => {
    setTimeLimit((prevTimeLimit) => {
      const newTimeLimit = Object.assign({}, prevTimeLimit);
      const { min, sec } = newTimeLimit;

      if (min <= 0 && sec <= 0) {
        stopTime();
        setIsTimeUp(true);
        return newTimeLimit;
      }

      if (newTimeLimit.min > 0 && newTimeLimit.sec <= 0) {
        newTimeLimit.min -= 1;
        newTimeLimit.sec = 60;
      }

      newTimeLimit.sec -= 1;

      return {
        min: zeroPaddingNum(newTimeLimit.min),
        sec: zeroPaddingNum(newTimeLimit.sec),
      };
    });
  }, []);

ストップ&リセットの処理

どちらもclearIntervalで1秒ごとに繰り返してた処理を取り消しています
つまりタイマーが止まります

  const stopTime = useCallback(() => {
    clearInterval(intervalID.current);
    setIsStop(true);
    setIsStart(false);
  }, []);

  const resetTime = useCallback(() => {
    clearInterval(intervalID.current);

    setTimeLimit(time);
    setIsReset(true);
    setIsStart(false);
    setIsStop(false);
    setIsTimeUp(false);
  }, []);

これでももちろんちゃんとタブを開いていれば動作します
ただ作業中に使うためのタイマーなのでバックグラウンドで動いて欲しい!ってことで改善しました

改善した実装方法

元々のやつは一応残したまま、別のものとして新たに作りました
なのでデザインとかは違います

https://haru-036.github.io/pomotime-v2/

そして本題のどうやって解決したかですが、Web Workersという仕組みを使いました

https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API
まだ自分も理解しきれていないのですが今回の場合、タイマーの処理をメインの処理とは別のスレッドに移すことでバックグラウンドでのタイマーの実行を可能にするという感じです

実際のコード

以下がWeb Workersを使ったコードになります
実はほぼchat-gptに書いてもらったものですが…

postMessageonmessageを使ってやり取りできるようです

Timer.tsx
  useEffect(() => {
    // Web Workerを初期化
    workerRef.current = new Worker(
      new URL("../timerWorker.ts", import.meta.url)
    );
    workerRef.current.onmessage = (event) => {
      setTimeLeft(event.data.timeLeft);
    };

    return () => {
      if (workerRef.current) {
        workerRef.current.terminate();
      }
    };
  }, []);
timerWorker.ts
let timerId: number | undefined;

self.onmessage = (event) => {
  const { command, endTime } = event.data;

  if (command === "start") {
    if (timerId) return; // 既に動作中なら無視

    const interval = 1000;
    timerId = setInterval(() => {
      const currentTime = Date.now();
      const timeLeft = Math.max(Math.floor((endTime - currentTime) / 1000), 0);

      self.postMessage({ timeLeft });

      if (timeLeft <= 0) {
        clearInterval(timerId);
        timerId = undefined;
      }
    }, interval);
  } else if (command === "stop") {
    clearInterval(timerId);
    timerId = undefined;
  }
};

あとはそれぞれのボタンに対応する関数を用意します

Timer.tsx
 const startTimer = () => {
    if (workerRef.current) {
      const endTime = Date.now() + timeLeft * 1000; // 現在時刻 + 残り時間
      workerRef.current.postMessage({ command: "start", endTime });
      setIsRunning(true);
    }
  };

  const stopTimer = () => {
    if (workerRef.current) {
      workerRef.current.postMessage({ command: "stop" });
      setIsRunning(false);
    }
  };

  const resetTimer = () => {
    if (workerRef.current) {
      workerRef.current.postMessage({ command: "stop" });
      setIsRunning(false);
      setTimeLeft(25 * 60);
    }
  };

冬なので雪を降らせた

こちらの記事を参考にタイマーが動いている間だけ雪を降らせるようにしてみました!
https://zenn.dev/y_ta/articles/8afc5714f8e21a

まとめ

開発している時は当たり前にずっと見ながら作業するから気づかなかったブラウザのバックグラウンドで実行させるにはどうすればいいか知れる良い機会でした

このpomotime-v2はデザインをあまり気にする時間がなかったので、もう少し改善して使いやすくするのと、元々の方にはあったその日の合計を記録するやつとかも加えていこうかなと思います!

25分と5分だけだし、タイムアップ時には通知がくるだけ(音はならない)の単純なアプリですが、作業する際にぜひ雪を降らしてみてください

https://haru-036.github.io/pomotime-v2/
https://github.com/haru-036/pomotime-v2

Discussion