ストップウォッチを作成しました【JavaScript/Vanilla/React/useEffect】

JavaScript

JavaScriptでストップウォッチを作りました!
普通のJavaScript(Vanilla)とReactバージョンを作ったのでメモしておきます。

ストップウォッチ(JavaScript版)

まずは、普通のJavaScriptでつくるストップウォッチがこちらです。

See the Pen stop-watch by donguri2020 (@m-ke) on CodePen.

スタート・ストップはもちろん、リセットボタンの機能も実装しました。

コード全体

HTMLとJavaScriptのコード全体がこちらです!CSSはCodePenを参考にしてください。

HTML
シンプルにタイマーとボタンが並んでいるだけです。タイマーの初期値は「00:00:00」です。
ボタンはdisabled属性でスタートのみ有効にしておきます。

  <h1>ストップウォッチ(JavaScript)</h1>
  <time>00:00:00</time>
  <div>
    <button id="onClickStart">スタート</button>
    <button id="onClickStop" disabled>ストップ</button>
    <button id="onClickReset" disabled>リセット</button>
  </div>

JavaScript

const onClickStart = document.getElementById("onClickStart")
const onClickStop = document.getElementById("onClickStop")
const onClickReset = document.getElementById("onClickReset")
const time = document.querySelector("time");

let startTime = 0;
let running = false;
let timerInterval = undefined;
let currentTime = "";
let calcTime = 0;

 // ボタンをクリックした時の処理
const displayTimer = () => {
    timerInterval = window.setInterval(() => {

      // 現在の時間からスタートした時間をマイナスする
      calcTime = Date.now() - startTime;

      // タイムスタンプを時間に変換します
      const currentTime = new Date(calcTime);
      const h = String(currentTime.getHours() - 9).padStart(2, "0");
      const m = String(currentTime.getMinutes()).padStart(2, "0");
      const s = String(currentTime.getSeconds()).padStart(2, "0");
      // const ms = String(currentTime.getMilliseconds()).padStart(3, "0");

      // time.textContent = `${h}:${m}:${s}:${ms}`;
      time.textContent = `${h}:${m}:${s}`;
      // ミリ秒表示の場合、10
      // 秒表示の場合 1000
    }, 1000);
}

// スタートボタンクリック(イベント)
onClickStart.addEventListener('click', function(){
   startTime = Date.now() - calcTime;
   displayTimer();
   // ボタンの状態
   onClickStart.disabled = true;
   onClickStop.disabled = false;
   onClickReset.disabled = false;
})

// ストップボタンクリック(イベント)
onClickStop.addEventListener('click', function(){
   window.clearInterval(timerInterval);
   // ボタンの状態
   onClickStart.disabled = false;
   onClickStop.disabled = true;
   onClickReset.disabled = false;
})

// リセットボタンクリック(イベント)
onClickReset.addEventListener('click', function(){
  calcTime = 0;
  time.textContent = "00:00:00";
  onClickStart.disabled = false;
  window.clearInterval(timerInterval);
  // ボタンの状態
  onClickStart.disabled = false;
  onClickStop.disabled = true;
  onClickReset.disabled = true;
})

ストップウォッチ制作のポイント(JavaScript)

制作で躓いたところなど、ポイントをメモしておきます。

タイマーの経過時間の求め方

タイマーの経過時間は、現在の時刻から、スタートした時刻をマイナスすることで求めます。
現在の時刻と、スタート時刻を取得する必要があります。

経過時間 = 現在の時刻スタート時刻

スタート時刻はスタートをクリックした時、現在の時間はそのままの意味で、今現在の時刻を取得します

スタートボタンをクリックした時の処理

スタートボタンをクリックすると、タイマーを開始します。

スタート時刻は、スタートボタンをクリックした時に取得します。
取得するにはDate.now()メソッドを使用します。Date.now()メソッドの引数を指定しない場合、現時刻、この場合、スタートボタンをクリックした時点の時刻を取得します。この値をstartTimeに格納します。

// スタートボタンクリック(イベント)
onClickStart.addEventListener('click', function(){
   startTime = Date.now();
})

Dateオブジェクトとは

Dateオブジェクトは日付を操作できるオブジェクトです。
今回は主にDateオブジェクトのDate.now()メソッドを使います。

Date.now()メソッド
UTC (協定世界時) での1970 年1月1日0時0分0秒から現在までの経過時間をミリ秒単位で返します。

Date.now()で取得した値は、主にタイムスタンプと呼ばれます。
「1663405774737」のような数字が取得できると思います。

取得したタイムスタンプを時・分・秒に変換する

先程のタイマーの経過時間の求め方をコードに当てはめると次のようになります。
ここでのDate.now()では現在の時刻を取得します。calcTimeに経過時間を格納します。

// 現在の時間からスタートした時間をマイナスする
calcTime = Date.now() - startTime;

これで、calcTimeに経過時間のタイムスタンプが格納されますが、このままだと、「2003」のような数字になるので、時間として扱えません。次のようにタイムスタンプを時間に変換します。

// タイムスタンプを時間に変換します
const currentTime = new Date(calcTime);
const h = String(currentTime.getHours() - 9).padStart(2, "0");
const m = String(currentTime.getMinutes()).padStart(2, "0");
const s = String(currentTime.getSeconds()).padStart(2, "0");

new Date()の引数に何も入力がなければ現在時刻を取得しますが、タイムスタンプを設定すれば、その時刻(※今回は経過時間)を取得できます。

// calcTimeは経過時間のタイムスタンプを格納している
const currentTime = new Date(calcTime);

ここでのポイントはnewDate()で取得したUTC (協定世界時)の時刻が、日本標準時 (JST) の時刻と9時間ずれていることです。なので、時を取得するgetHours()の値から9をマイナスします。

currentTime.getHours() - 9

あとは、padStart()メソッドで表示桁数を合わせたり、String()で文字列に変換しています。
最終的にはこれらの文字列を組み合わせてブラウザに表示させます。

// 00:01:30 のように表示される。※timeは表示領域のDOM要素 
time.textContent = `${h}:${m}:${s}`;

setInterval()で一定期間繰り返す

経過時間を求めることができましたが、このままだと画面をリロードしないと時間が進みません。今回、setInterval()メソッドで処理を繰り返し実行します。

次のコードは<繰り返す時間>ごとに処理を繰り返します。設定する<繰り返す時間>はミリ秒になるので、一秒なら1000を設定します。

let timerInterval = window.setInterval(() => {
    ...実行する内容
}, <繰り返す時間>)

// 繰り返し動作を取り消す。
window.clearInterval(timerInterval);

セットしたclearInterval()メソッドを取り消すには、clearInterval()メソッドの引数にsetInterval()を格納した変数を設定します。

ストップボタンをクリックした時の処理

ストップボタンをクリックしたらタイマーを停止し、再度スタートボタンをクリックしたら停止した時間から再開するようにします。

まず、ストップボタンがクリックされた時に、clearInterval()メソッドでタイマーを停止します。

// ストップボタンクリック(イベント)
onClickStop.addEventListener('click', function(){
  //タイマーをストップ
  window.clearInterval(timerInterval);
})

停止した時間から再開する

タイマーをストップした状態で再スタートした場合、停止した時点でのタイマーではなく、また1秒から開始されます。
これは、スタートボタンをクリックしたら、現在時刻を取得する為です。

// スタートボタンクリック(イベント)
onClickStart.addEventListener('click', function(){
   startTime = Date.now();
})

再スタートした時に、停止時間から再開するには、ストップした時点の経過時間を取得しておく必要がありますが、その値はcalcTimeに保持されています。

クリックした時点で、現時刻から経過時間(※変数calcTime)をマイナスします。calcTimeの初期値は0ですが、時間が経過すれば値が格納されるので、その分マイナスする訳です。

// スタートボタンクリック(イベント)
onClickStart.addEventListener('click', function(){
   // 現在の時刻から経過時間をマイナスする(初期値は0)
   startTime = Date.now() - calcTime;
   displayTimer();
})

これで、再度スタートボタンをクリックしても、停止した時間から再開できます。

リセットボタンをクリックした時の処理

リセットボタンをクリックした時、経過時間を0にして、タイマー表示も「00:00:00」に戻します。

リセットボタンをクリックしたら、タイマーをストップして、経過時間とタイマー表示を初期値に戻します。

const onClickStart = document.getElementById("onClickStart")
const onClickStop = document.getElementById("onClickStop")
const onClickReset = document.getElementById("onClickReset")

// リセットボタンクリック(イベント)
onClickReset.addEventListener('click', function(){
  // 経過時間
  calcTime = 0;
  // タイマー表示
  time.textContent = "00:00:00";
  // タイマーをストップ
  window.clearInterval(timerInterval);
})

連続クリックによるバグを防ぐ

これでほぼ完成ですが、スタートボタンを連続でクリックすると、その度にsetInterval()メソッドが実行され、動作がおかしくなります。

連続クリックを防ぐとともに、もう少し分かりやすいUIに変更します。

具体的には次のような動作にします。

  • 最初はスタートボタンだけクリックできる状態
  • タイマーを開始したら、スタートボタン無効、ストップとリセットボタンは有効
  • タイマーを停止したら、ストップボタン無効、スタートとリセットボタンは有効
  • タイマーをリセットしたら、リセットとストップボタン無効、スタートは有効 ※最初の状態に戻る

次のような動きになります。分かりやすいように有効なボタンにはCSSで色をつけました。

スタート、ストップ、リセットをクリックした時のボタン無効はdisabled属性で設定します。
関係ある箇所を抜き出したコードがこちらです。disabled属性がtureならボタンクリックは無効になり、falseなら有効になります。

const onClickStart = document.getElementById("onClickStart")
const onClickStop = document.getElementById("onClickStop")
const onClickReset = document.getElementById("onClickReset")

// スタートボタンクリック(イベント)
onClickStart.addEventListener('click', function(){
   // ボタンの状態
   onClickStart.disabled = true;
   onClickStop.disabled = false;
   onClickReset.disabled = false;
})

// ストップボタンクリック(イベント)
   // ボタンの状態
   onClickStart.disabled = false;
   onClickStop.disabled = true;
   onClickReset.disabled = false;
})

// リセットボタンクリック(イベント)
onClickReset.addEventListener('click', function(){
  // ボタンの状態
  onClickStart.disabled = false;
  onClickStop.disabled = true;
  onClickReset.disabled = true;
})

これでストップウォッチが完成しました!

ストップウォッチ(React版)

今度は、Reactでストップウォッチを作りました。動作はJavaScript版とまったく同じです。
主な違いは、変数をuseStateで管理し、setInterval()実行のタイミングをuseEffect()で調整していることです。
※以下のデモはTypeScriptで記述しています。

コード(全体)

ストップウォッチコンポーネントのコードはこちらになります!

StopWatch.tsx

import { useEffect, useState } from "react";
import "./stopwatch.css";

export const StopWatch = () => {
  const [displayTime, setDisplayTime] = useState("00:00:00");
  const [startTime, setStartTime] = useState(0);
  const [calcTime, setcalcTime] = useState(0);
  const [running, setRunning] = useState(false);
  const [btnDisabled, setDisabled] = useState({start: false, stop: true, reset: true})

  // ボタンをクリックした時の処理
  useEffect(() => {
    console.log("スタート・ストップが切り替わりました");
    let timerInterval: number | undefined = undefined;
    // タイマーが動いている場合
    if (running) {
      timerInterval = window.setInterval(() => {
        setcalcTime(Date.now() - startTime);
        // ミリ秒表示の場合、10
        // 秒表示の場合 1000
      }, 1000);
    }
    // クリーンアップ(タイマーをクリア)
    return () => {
      window.clearInterval(timerInterval);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [running]);

  // タイムスタンプを変換
  useEffect(() => {
    console.log("タイムスタンプを時間に変換します");
    const currentTime = new Date(calcTime);
    const h = String(currentTime.getHours() - 9).padStart(2, "0");
    const m = String(currentTime.getMinutes()).padStart(2, "0");
    const s = String(currentTime.getSeconds()).padStart(2, "0");
    const ms = String(currentTime.getMilliseconds()).padStart(3, "0");
    // ミリ秒表示
    // setDisplayTime(`${h}:${m}:${s}:${ms}`);
    setDisplayTime(`${h}:${m}:${s}`);
  }, [calcTime]);

  // スタートボタンクリック(イベント)
  const onClickStart = () => {
    setStartTime(Date.now() - calcTime);
    setRunning(true);
    setDisabled({start: true, stop: false, reset: false})
  };
  // ストップボタンクリック(イベント)
  const onClickStop = () => {
    setRunning(false);
    setDisabled({start: false, stop: true, reset: false})
  };

  // リセットボタンクリック(イベント)
  const onClickReset = () => {
    setRunning(false);
    setDisplayTime("00:00:00");
    setcalcTime(0);
    setDisabled({start: false, stop: true, reset: true})
  };

  console.log("マウントします");

  return (
    <div className="App">
      <h1>ストップウォッチ(React)</h1>
      <time>{displayTime}</time>
      <div className="btnArea">
        <button onClick={onClickStart} disabled={btnDisabled.start}>スタート</button>
        <button onClick={onClickStop} disabled={btnDisabled.stop}>ストップ</button>
        <button onClick={onClickReset} disabled={btnDisabled.reset}>リセット</button>
      </div>
    </div>
  );
};

ストップウォッチ制作のポイント(React)

コード内容はJavaScript版とほぼ同じですが、React独特の書き方があるのでメモしておきます。

タイマー処理はuseEffect()で実行するタイミングを調整する

まず、タイマー動作の状態を管理するrunningというstateを作成します。動作していたらtrue、していなかったらfalseを格納します。※stateはアプリケーションが保持しているデータの状態のことです。

  const [running, setRunning] = ueState(false);

runningはスタート、ストップ、リセットをクリックした時に更新されます。
スタートをクリックしたらtrue、それ以外はfalseが格納されます。

// スタートボタンクリック(イベント)
  const onClickStart = () => {
    setRunning(true);
  };
  // ストップボタンクリック(イベント)
  const onClickStop = () => {
    setRunning(false);
  };
  // リセットボタンクリック(イベント)
  const onClickReset = () => {
    setRunning(false);
  };

タイマー処理に関するコードは、useEffect()内に記載します。
useEffect()内に記載したコードは、依存配列が更新された時だけ実行されます。
依存配列は、先程作成したrunningを設定します。

タイマーを処理するsetInteral()は、if文でrunningがtrueの時だけ実行するようにします。これで、スタートボタンをクリックした時だけタイマーが開始されることになります。

   // ボタンをクリックした時の処理
  useEffect(() => {
    let timerInterval = undefined;
    // タイマーが動いている場合
    if (running) {
      timerInterval = window.setInterval(() => {
        setcalcTime(Date.now() - startTime);
      }, 1000);
    }
    // クリーンアップ(タイマーをクリア)
    return () => {
      window.clearInterval(timerInterval);
    };
  }, [running]); // runningが更新された時だけ実行

runningがtrueの時だけsetInterval()を実行するので、ストップ、リセットボタンをクリックした際は、タイマーが停止します。

※useEffect()がアンマウントした時に、クリーンアップでタイマーイベントを取り消すようにしましたが、今回はこの処理がなくても問題ないと思います。

【つまずいたところ】タイマー処理とタイムスタンプを変換する処理は分ける

つまずいたところをメモしておきます。
最初、取得した経過時間をタイムスタンプに変換する処理を、setInterval()に記載しました。
それだと何故か時間が更新されず、、。

うまくいかない例

  // ボタンをクリックした時の処理
  useEffect(() => {
    console.log("スタート・ストップが切り替わりました");
    let timerInterval: number | undefined = undefined;
    // タイマーが動いている場合
    if (running) {
      timerInterval = window.setInterval(() => {
        console.log("経過時間を取得します");
        setcalcTime(Date.now() - startTime);

        console.log("タイムスタンプを時間に変換します");
        const currentTime = new Date(calcTime);
        const h = String(currentTime.getHours() - 9).padStart(2, "0");
        const m = String(currentTime.getMinutes()).padStart(2, "0");
        const s = String(currentTime.getSeconds()).padStart(2, "0");
        const ms = String(currentTime.getMilliseconds()).padStart(3, "0");
        setDisplayTime(`${h}:${m}:${s}`);
      }, 1000);
    }

次のように、タイムスタンプを変換する処理をuseEffect()で分けたらうまくいきました。
依存配列は、経過時間を管理するcalcTimeです。時間が進むとcalcTimeが更新され、それに合わせてタイムスタンプ変換の処理も実行されます。

うまくいった例

 // ボタンをクリックした時の処理
  useEffect(() => {
    let timerInterval = undefined;
    // タイマーが動いている場合
    if (running) {
      timerInterval = window.setInterval(() => {
        setcalcTime(Date.now() - startTime);
      }, 1000);
    }
  }, [running]);

  // タイムスタンプを変換
  useEffect(() => {
    console.log("タイムスタンプを時間に変換します");
    const currentTime = new Date(calcTime);
    const h = String(currentTime.getHours() - 9).padStart(2, "0");
    const m = String(currentTime.getMinutes()).padStart(2, "0");
    const s = String(currentTime.getSeconds()).padStart(2, "0");
    const ms = String(currentTime.getMilliseconds()).padStart(3, "0");

    setDisplayTime(`${h}:${m}:${s}`);
  }, [calcTime]);

ボタンの状態を管理する

JavaScript版では、連続クリックによるバグがありましたが、React版では連続でクリックしても動作に問題がありません。

多分ですが、これはuseState()の動作に関係があります。更新stateが、現在のstateと全く同じ値を返す場合、更新がないとみなされます。その為、何度スタートボタンをクリックしてもuseState()が再実行されることはありません。

とはいえ、ボタンの状態をJavaScript版と同じように再現したいので、ボタンの状態を管理するbtnDisabledというstateを用意します。

 const [btnDisabled, setDisabled] = useState({start: false, stop: true, reset: true})

stateはオブジェクトになっており、start、stop、resetプロパティを持ちます。なお、値はboolean型です。

stateはそれぞれのボタがクリックされた時に、更新します。

 // スタートボタンクリック(イベント)
  const onClickStart = () => {
    setDisabled({start: true, stop: false, reset: false})
  };
  // ストップボタンクリック(イベント)
  const onClickStop = () => {
    setDisabled({start: false, stop: true, reset: false})
  };
  // リセットボタンクリック(イベント)
  const onClickReset = () => {
    setDisabled({start: false, stop: true, reset: true})
  };

あとは、buttonタグのdisabled属性にbtnDisabledの値を反映すれば、ボタンの有効、無効の表示を切り替えられます。ちなみにdisabled属性がtrueならボタンクリックが無効、falseなら有効になります。

  return (
    <div className="App">
        <button onClick={onClickStart} disabled={btnDisabled.start}>
          スタート
        </button>
        <button onClick={onClickStop} disabled={btnDisabled.stop}>
          ストップ
        </button>
        <button onClick={onClickReset} disabled={btnDisabled.reset}>
          リセット
        </button>
    </div>
  );

まとめ

今回、ストップウォッチを作ってJavaScriptとReactの違いを比べてみました。
同じ動作とはいえ、再現する考え方が異なるので、勉強になりました。