Reactで学ぶ 学習タイマー開発(第3回)~ずれないタイマーにする~【GitHub対応】

GitHubでつくって学ぶ

はじめに

第2回では、setIntervalで「1秒ごとに1減らす」タイマーを作った。
しかし、このタイマーは処理が詰まったり、タブが裏に回ったりすると、実際の時間より遅れてしまうことが確かめられた。

第3回では、このずれを解消する。考え方を変えて、終了予定の時刻と、今の時刻の差から、残り時間を計算する。そのために、Date.now()とuseRefを学ぶ。

今回やること

  • ずれの原因と、解決の考え方を知る
  • Date.now()で、現在の時刻を取得する
  • useRefで、画面に表示しない値を持つ
  • 残り時間を、ミリ秒で扱うようにする
  • ずれないことを、もう一度実験で確かめる

画面の見た目は、第2回と変わらない。変わるのは、中身の計算方法である。

実行環境

第1・2回と同じである。ソースコードは、次の筆者のリポジトリの03_accurate-timerフォルダにある。第2回の完成コードに、今回の変更を加えたものである。

GitHub – HappyTalk10/pc-labo-react-study-timer
Contribute to HappyTalk10/pc-labo-react-study-timer development by creating an account on GitHub.
cd 03_accurate-timer
npm install
npm run dev

ずれない方法を考える

第2回の方式は、「1秒たった」と数える方式だった。

setRemainingSeconds((prev) => prev - 1);

この方式は、1回でも数え損なうと、その分が戻らない。処理が詰まって実行が遅れても、「1」しか減らないので、遅れが積み重なっていく。

そこで、数えるのをやめる。代わりに、次のように考える。

  1. 開始した時に、「いつ終わるか(終了予定の時刻)」を決めておく
  2. 確かめる時は、「終了予定の時刻 − 今の時刻」を計算する

これなら、確かめる処理が遅れても、遅れた時点で正しい残り時間が求まる。数え損なう、ということがない。時計を見て「あと何分か」を判断するのと、同じ考え方である。

Date.now()

時刻を扱うには、Date.now()を使う。入門編の第2回で、new Date(...)を学んだが、Date.now()は、もっと簡単である。

const now = Date.now();
console.log(now); // 例:1760000000000

Date.now()は、「1970年1月1日0時0分0秒から、今まで何ミリ秒たったか」を、数値で返す。大きな数値だが、引き算するだけで、時間の差がミリ秒で求まるのが便利である。

終了予定の時刻と、残り時間は、次のように求められる。

// 25分後の時刻
const endTime = Date.now() + 25 * 60 * 1000;

// 残り時間(ミリ秒)
const remaining = endTime - Date.now();

1秒は1000ミリ秒なので、25分は25 * 60 * 1000ミリ秒である。

useRef

終了予定の時刻は、どこに持てばよいだろうか。ここで、useRefを使う。

useRefは、値をしまっておく箱を作る機能である。

const endTimeRef = useRef(null);

endTimeRef.current = 12345; // 値を入れる
console.log(endTimeRef.current); // 値を取り出す

箱の中身は、.currentで読み書きする。useStateとの違いは、次のとおりである。

useStateuseRef
値を変えると画面が再描画される画面は再描画されない
再描画しても、値は保持される保持される
使う場面画面に表示する値画面に表示しない値

終了予定の時刻は、画面に表示しない。表示するのは、「残り時間」である。そのため、useStateではなく、useRefに入れる。

普通の変数(let endTime = ...)では、だめなのだろうか。コンポーネントは、再描画されるたびに、関数が最初から実行し直される。そのため、普通の変数の値は、再描画のたびに消えてしまう。useRefなら、再描画をまたいで、値が残る。

なお、refの値は、描画の途中ではなく、ボタンが押された時の処理や、useEffectの中で読み書きする、というのがReactの決まりである。

残り時間をミリ秒で持つ

Date.now()はミリ秒で値を返すので、残り時間も、ミリ秒で持つことにする。

const TOTAL_MS = WORK_MINUTES * 60 * 1000; // ミリ秒で持つ

const [remainingMs, setRemainingMs] = useState(TOTAL_MS);

画面に表示する時は、これまでどおり秒にして、TimerDisplayに渡す。

const remainingSeconds = Math.ceil(remainingMs / 1000); // 表示用(切り上げ)

Math.ceilは、小数点以下を切り上げる関数である。たとえば、残りが1496.5秒なら、1497秒と表示する。切り上げにすると、「00:00」と表示されるのは、本当に0になった瞬間だけになる。切り捨てだと、残りが0.5秒の時点で、「00:00」と表示されてしまう。

remainingSecondsは、remainingMsから計算できる値なので、これもstateにしない。第2回で学んだとおりである。TimerDisplayは、秒を受け取るままなので、変更しなくてよい。

Appコンポーネント

src/App.jsxの全体である。

import { useEffect, useRef, useState } from "react";
import SubjectInput from "./components/SubjectInput.jsx";
import TimerDisplay from "./components/TimerDisplay.jsx";
import Controls from "./components/Controls.jsx";
import "./App.css";

const WORK_MINUTES = 25;
const TOTAL_MS = WORK_MINUTES * 60 * 1000; // ミリ秒で持つ
const TICK_MS = 200; // 残り時間を確かめる間隔

export default function App() {
  const [subject, setSubject] = useState("");
  const [remainingMs, setRemainingMs] = useState(TOTAL_MS);
  const [isRunning, setIsRunning] = useState(false);

  // 終了予定の時刻。画面には表示しないので、state ではなく ref に入れる
  const endTimeRef = useRef(null);

  // state から計算できる値は、state にしない
  const canStart = subject.trim() !== "";
  const isFinished = remainingMs === 0;
  const isActive = isRunning && !isFinished; // 実際に動いている状態
  const hasStarted = remainingMs < TOTAL_MS;
  const remainingSeconds = Math.ceil(remainingMs / 1000); // 表示用(切り上げ)

  // 動いている間だけ、残り時間を確かめ続ける
  useEffect(() => {
    if (!isActive) return;

    const id = setInterval(() => {
      // 数えるのではなく、「終了予定時刻 − 今の時刻」を計算する
      setRemainingMs(Math.max(endTimeRef.current - Date.now(), 0));
    }, TICK_MS);

    // 後片付け:タイマーを止める
    return () => clearInterval(id);
  }, [isActive]);

  const handleStart = () => {
    // 終了予定時刻 = 今の時刻 + 残り時間
    endTimeRef.current = Date.now() + remainingMs;
    setIsRunning(true);
  };

  const handlePause = () => {
    // 止めた瞬間の残り時間を、正確に取っておく
    setRemainingMs(Math.max(endTimeRef.current - Date.now(), 0));
    setIsRunning(false);
  };

  const handleReset = () => {
    setIsRunning(false);
    setRemainingMs(TOTAL_MS);
  };

  let message;
  if (isFinished) {
    message = `お疲れさまでした!「${subject}」を${WORK_MINUTES}分、学習しました`;
  } else if (isActive) {
    message = `「${subject}」を学習中です`;
  } else if (hasStarted) {
    message = `「${subject}」を一時停止中です`;
  } else if (canStart) {
    message = `「${subject}」を${WORK_MINUTES}分、学習します`;
  } else {
    message = "科目を入力すると、開始できます";
  }

  return (
    <main className="app">
      <h1>学習タイマー</h1>

      <SubjectInput
        value={subject}
        onChange={setSubject}
        disabled={hasStarted}
      />

      <TimerDisplay remainingSeconds={remainingSeconds} />

      <Controls
        isActive={isActive}
        hasStarted={hasStarted}
        isFinished={isFinished}
        canStart={canStart}
        onStart={handleStart}
        onPause={handlePause}
        onReset={handleReset}
      />

      <p className="note">{message}</p>
    </main>
  );
}

変更したのは、App.jsxだけである。ほかのファイルは、第2回から変わっていない。

変更点を順に確認しよう

開始する時に、終了予定の時刻を決める

const handleStart = () => {
  // 終了予定時刻 = 今の時刻 + 残り時間
  endTimeRef.current = Date.now() + remainingMs;
  setIsRunning(true);
};

「開始」と「再開」は、どちらもこの関数が呼ばれる。最初は、残り時間が25分なので、25分後が終了予定の時刻になる。一時停止のあとの再開では、その時点の残り時間を足すので、止めていた時間は、残り時間に影響しない。

一定間隔で、差を計算する

const id = setInterval(() => {
  setRemainingMs(Math.max(endTimeRef.current - Date.now(), 0));
}, TICK_MS);

第2回では、prev - 1と、数えていた。今回は、endTimeRef.current - Date.now()で、終了予定の時刻と今の時刻の差を計算している。Math.max(..., 0)は、差がマイナスにならないよう、0を下限にしている。

間隔は、1秒(1000ミリ秒)ではなく、200ミリ秒にしている。1秒ごとに確かめると、秒が切り替わるタイミングと、確かめるタイミングがずれて、表示が最大1秒近く遅れて見えるためである。200ミリ秒ごとに確かめても、計算は軽いので、問題はない。

この処理は、もう「間隔」に頼っていない。間隔が遅れても、遅れた時点で、正しい残り時間が求まる。

一時停止の時に、正確な残り時間を取っておく

const handlePause = () => {
  // 止めた瞬間の残り時間を、正確に取っておく
  setRemainingMs(Math.max(endTimeRef.current - Date.now(), 0));
  setIsRunning(false);
};

stateに入っている残り時間は、最後に確かめた時点のもので、最大200ミリ秒ほど古い。止めた瞬間に、もう一度計算しておくことで、再開した時にその分がずれないようにしている。

動作確認

npm run devで起動して、第2回と同じ動きをすることを確認しよう。

  • 開始すると、残り時間が減っていく
  • 一時停止で止まり、再開で続きから減る
  • リセットで、「25:00」に戻る

見た目は、第2回と変わらない。違いは、次の実験で確かめる。

実験:もう一度ずれを確かめる

第2回と同じ実験を、もう一度行う。
ブラウザのタブでタイマーを開き、スマートフォンなどのストップウォッチを用意する。

  1. 科目を入力して、タイマーの「開始」ボタンを押す。同時に、ストップウォッチも開始する
  2. タイマーとストップウォッチは、止めずに動かしたまま、タイマーを表示しているそのタブで、F12キーを押して、開発者ツール(DevTools)を開く
  3. 「コンソール」のタブを選び、>が表示された入力欄をクリックする
  4. 次のコードを貼り付けて、Enterキーを押す。5秒間、ページが固まったように見える
  5. 固まりが解除されたら、すぐに、タイマーの「一時停止」を押そう。同時にストップウォッチも止める

操作は、必ず、タイマーを表示しているタブで行う。別のタブで開発者ツールを開いても、タイマーには影響しない。

{ const t = Date.now(); while (Date.now() - t < 5000) {} }

貼り付け方や、貼り付けられない場合の対処は、第2回の「実験:ずれを体験する」を参照してほしい。

結果を比べる

第2回と同じく、次の2つを比べる。

  • 実際の経過時間:ストップウォッチの表示
  • タイマーが進んだ時間:「25:00」から、今のタイマーの表示を引いた値

第2回では、タイマーが遅れていた。今回は、ほとんど差がない。止めるタイミングの違いで、1秒ほどの誤差は出る。また、タイマーは、残り時間は切り上げて表示しているので、タイマーが進んだ時間は、最大1秒ほど少なく見える。差が1〜2秒以内なら、実験は成功である。

筆者の環境(Chrome)では、前回は、実際の経過(ストップウォッチ)が11秒ほどの時に、第2回のタイマーは7秒分しか進んでいなかった。今回のタイマーは、11秒分進んでいた。

ブラウザが、裏のタブのタイマーの実行回数を減らすことがあるのは、今回も同じである。
ただ、次に実行された時に、「終了予定の時刻 − 今の時刻」を計算するので、そのとき正しい残り時間が表示される。

今回のまとめ

  • 「1減らす」と数える方式は、数え損なうと遅れが積み重なる
  • 「終了予定の時刻 − 今の時刻」を計算する方式なら、確かめる処理が遅れても、ずれない
  • Date.now()は、現在の時刻をミリ秒の数値で返す。引き算すると、時間の差が求まる
  • useRefは、画面に表示しない値をしまっておく箱である。値を変えても、再描画されない
  • 時刻はミリ秒で扱い、表示する時だけ、Math.ceilで秒に切り上げる

次回予告

第4回では、アナログ時計の針で、残り時間を表示する。SVGで針を描いて、requestAnimationFrameでなめらかに動かす。今回、残り時間をミリ秒で持つようにしたことが、そこで活きてくる。

タイトルとURLをコピーしました