サンプルコードタイマー処理日付・時刻

【JavaScript】 サクッとストップウォッチを作ってみる

更新日:2021/04/28

JavaScriptでブラウザ上で動くストップウォッチを作成してみます。
今回は開始と停止だけでなく、一時停止からの再開やラップ測定も組み込んでみます。

 

完成デモ

 

ストップウォッチの基本的な考え方

ストップウォッチの時間計測は、setInterval()またはsetTimeout()を使用します。

setInterval( コールバック , 呼び出し時間 ) … 一定時間間隔で処理を呼び出す
setTimeout( コールバック , 呼び出し時間 ) …一定時間経過後に一回だけ処理を呼び出す

呼び出し時間はミリ秒で指定します。

指定時間後に一回だけコールバックを呼び出す場合は、setTimeout()を使用します。
一定時間ごとにコールバックを呼び出す場合はsetInterval()を使用しますが、setTimeout()を再使用することも可能です。

経過時間の計測

経過時間計測な次のようにおこないます。

  1. 開始時にDate.now()でタイムスタンプを取得する。
  2. タイマーを開始する
  3. タイマー呼び出し時にDate.now()でタイムスタンプを取得する。
  4. 3で取得したタイムスタンプから、2で取得したタイムスタンプを引き算する
  5. 4の値を経過時間として処理する
  6. タイマー呼び出しまで待ち、呼び出されたら3へ

この処理で、経過時間がミリ秒単位で取得できます。

呼び出し時間を加算することで、経過時間を積み上げるのはNGです。

NG


let time = 0; // 経過時間

   // 10ミリ秒ごとにタイマー呼び出し

setInterval( ()=>{
          //  10ミリ秒ごとに呼び出されるので10を加算
        time += 10; 
    }, 10 );

setTimeout()またはsetInterval()は、呼び出し時間で指定した時間ぴったりに、コールバックが呼び出すわけではありません。

メインの流れが終わった後、同じような待機処理を確認して、順番が回ってきたときに指定時間が経過していたらコールバックが呼び出されます。

また、ブラウザによっては呼び出し時間の最小値が設定されているケースがあります。
この場合は、それより小さい時間をセットしても最小値に丸められます。

解決方法として、上記の方法が採用されます。

一時停止/再開

ストップウォッチの一時停止時に、次の処理をおこないます。

  1. 一時停止までの経過時間を、積算時間として保存する

再開時は、次の処理をおこないます。

  1. 開始時にDate.now()でタイムスタンプを取得する。
  2. タイマーを開始する
  3. タイマー呼び出し時にDate.now()でタイムスタンプを取得する。
  4. 3で取得したタイムスタンプから、2で取得したタイムスタンプを引き算する
  5. 4の値に積算時間を加算した結果を経過時間として処理する
  6. タイマー呼び出しまで待ち、呼び出されたら3へ

この処理は経過時間の計測とほぼ同じです。
異なるのは4だけなので、経過時間の計測の処理は積算時間を0として計算するのが妥当な処理になります。

ミリ秒の数値を分:秒.ミリ秒で表示

ミリ秒を分:秒.ミリ秒で表示する場合、方法の一つとしてDateオブジェクトを使用する方法があります。

Dateオブジェクト使用例


const millisecond = 366100;

const date = new Date( millisecond );

const dateString = date.getMinutes() + ":" + date.getSeconds() + "." + date.getMilliseconds();
// 6:6.100

しかしストップウォッチは動作間隔が短いので、可能な限り処理効率を上げるのが望ましいです。

Dateオブジェクトへの変換は、気にするほどの時間ロスではないとのご指摘を頂きました。
詳細はこちら

そこで、単純な計算式で分、秒、ミリ秒を求めます。

■分の計算

Math.floor( millisecond / 60000 )

■秒の計算

Math.floor( millisecond % 60000 / 1000)

■ミリ秒の計算

Math.floor( millisecond % 1000)

また桁合わせも必要です。

次にように数値を文字に変換し、先頭を"0"で穴埋めします。

Math.floor( millisecond / 60000).toString().padStart(2,"00")

穴埋めについては次の記事で紹介しているので読んでみてください。
【JavaScript】 ゼロやスペースで埋めして桁揃えする

 

ソースコード

今回は"開始/リセット"、"一時停止/再開"、"ラップ"の3つのボタンを用意しました。

■開始/リセット

タイマー停止中で一時停止でなければ、ストップウォッチをスタートします。
タイマー動作中または一時停止ならば、ストップウォッチを停止して表示をリセットします。

■一時停止/再開

タイマー動作中なら、ストップウォッチを一時停止します。
一時停止中なら、ストップウォッチを再開します。
これ以外は、何もしません。

■ラップ

タイマー動作中なら、現在の経過時間をラップ表示します。

この仕様を元に次のHTMLを作成しました。

HTML

HTML


<p><button id="start">開始/リセット</button><button id="pause">一時停止/再開</button><button id="wrap">ラップ</button></p>
<p id="watchArea"></p>
<textarea id="wrapArea"></textarea>

JavaScript

次はJavaScriptです。

なお、このコードは有識者様より一部ご指摘を頂いております。
ご指摘内容については、こちらをご覧ください。

JavaScript

"use strict";

window.addEventListener( "DOMContentLoaded" , ()=> {
    /**
     * @param watchCallBack 経過時間報告用コールバック
     * @param wrapCallBack ラップ報告用コールバック
     */
    const getStopWatch = function ( watchCallBack , wrapCallBack ){
        let accumulatedTime = 0,    // 積算時間
            currentTime=null,       // タイマー開示タイムスタンプ
            timerId=null;           // setInterval() の返り値

            // リセット処理
        const reset = () =>{
            timerOff(); accumulatedTime = 0; currentTime=null;
                // リセットされたことをnullで通知
            watchCallBack( null ); wrapCallBack ( null );
        };
            // 開始処理
        const start = () =>{ currentTime = Date.now();timerOn(); };
            // 一時停止処理
        const pause = () =>{
                // これまでの経過時間を退避
            accumulatedTime = getNowTime();
            timerOff();
            currentTime = null;
        };
            // 再開処理
        const resume = () =>start();
            // ラップ報告処理
        const wrap = () =>wrapCallBack( getNowTime() );
            // 経過時間の算出
        const getNowTime = () =>accumulatedTime + Date.now() - currentTime;

            // タイマー停止処理
        const timerOff = () => {
            if( timerId === null ) return;
            clearInterval(timerId);
            timerId = null;
        };
            // タイマー開始処理
        const timerOn = () => {
            if( timerId !== null ) clearTimeout(timerId);
            timerId = setInterval(()=>watchCallBack( getNowTime() ),10);
        };

        reset();

            // 必要な機能だけ返す
        return Object.freeze({
            start:()=> currentTime === null && accumulatedTime === 0 ?   start()  : reset(),
            pause:()=> currentTime === null ? ( accumulatedTime === 0 ? false : resume() ) : pause(),
            wrap:()=> currentTime === null ?  false : wrap(),
        });
    };

        // ミリ秒を画面表示する形式に変換
    const timeString = time =>`${
        Math.floor(time / 60000).toString().padStart(2,"00")
    }:${
        Math.floor(time % 60000 / 1000).toString().padStart(2,"00")
    }.${
        Math.floor(time % 1000).toString().padStart(3,"000").slice(0,2)
    }`;

    const watchArea = document.getElementById("watchArea");
    const wrapArea = document.getElementById("wrapArea");

    const stopWatchObj = getStopWatch(
         time => watchArea.textContent = time === null ? "00:00.00" : timeString( time ) ,
         time => wrapArea.value = time === null ? "" : wrapArea.value + "\n" + timeString( time )
    );

    const buttonDefine = [
            { id:"start" , listener:()=>stopWatchObj.start() },
            { id:"pause" , listener:()=>stopWatchObj.pause() },
            { id:"wrap"  , listener:()=>stopWatchObj.wrap()  }
        ];
    buttonDefine.forEach( e => document.getElementById(e.id).addEventListener("click",e.listener));
});

ストップウォッチの時間計測処理をオブジェクトとして独立させ、ボタンが押されたらメソッドを呼び出す流れになっています。

有識者様からのご指摘

上記のコードですが、有識者様から次の2つのご指摘を頂きました。

  1. clickイベントよりも、mousedownイベントを使用すべき(スマホはtouchstart)
  2. 経過時間を文字列に変換するのは、toISOStringで十分

ご指摘ありがとうございます。
いつも正しい情報をお届けしようと自分にとって既知の情報も含めて何度も調査していますが、それでも何か落とし穴がないか不安でいっぱいです。
今回も、ストップウォッチの内部的な処理に意識が集中してしまって、クリックのイベント補足の効率なんて思いもよりませんでした。
本当に感謝です。

では、ご指摘について解説していきます。

clickイベントよりも、mousedownイベントを使用すべき

コードの最後の部分です。

問題のコード


buttonDefine.forEach( e => document.getElementById(e.id).addEventListener("click",e.listener));

"click" を "mousedown" に変更すべきということですね。

"click"は、マウスボタンが押されて離れた後に発火します。
一方"mousedown"は、マウスボタンが押された時点で発火します。

"mousedown"の方が発生が速いのでこちらを使用すべきなのですが、実は"click"はストップウォッチでの使用は不適切です。

ボタンを押してから離すまでの時間は、人によって異なります。
押した瞬間は同じでも、"click"イベントが発生するのは人によって異なるということです。
これでは、正確な時間を計測することができませんね。

なお、スマホは"touchstart"を使用するようです。
こちらもご指摘いただいていて、スマホとの切り分け例を提示していただきました。

touchstart(スマホ) と mousedown(PC)の切り分け


window.ontouchstart !== undefined ? 'touchstart' : 'mousedown';

経過時間を文字列に変換するのは、toISOStringで十分

これは、次のコードについてのご指摘です。

指摘のあったコード


    const timeString = time =>`${
        Math.floor(time / 60000).toString().padStart(2,"00")
    }:${
        Math.floor(time % 60000 / 1000).toString().padStart(2,"00")
    }.${
        Math.floor(time % 1000).toString().padStart(3,"000").slice(0,2)
    }`;

いろいろ計算していますが、次のコードで十分だそうです。

toISOStringを使用


const timeString = time => new Date(time).toISOString().slice(14, 22);

toISOString()は、ISO形式で日時をフォーマットしてくれるメソッドです。

ISO形式


YYYY-MM-DDTHH:mm:ss.sssZ

各要素(年や月・分など)の桁数は固定です。
そのためsliceメソッドで、経過時間に相当する箇所を抜き出すことができます。

この処理は数値(ミリ秒)をDateオブジェクトに変換しています。
この時間ロスが大きいと思ったのですが、「古いCore i3ノートPCでさえ100万回繰り返しても1秒に満たない」との情報を頂きました。
ストップウォッチ程度ならそれほど問題ないそうです。

 

正確なストップウォッチはムリ

せっかくストップウォッチを作るのだから、正確なものにしたいですね。

しかしJavaScriptの特性上、ボタンが押されてからスクリプトのコードが呼び出されるまで待機時間があります。
この時間はその時の状況によって大きく変わるため、コード内で補正するのが難しいです。

またブラウザによっては、タイマー呼び出し時間の最低値が設定されているケースはあり、この場合はそれ以上の精度で計測できません。

つまり、JavaScriptでミリ秒単位で正確な時間計測をすることを目的にストップウォッチを作成するのはやめておくべきということです。

おおよその時間把握に使用しましょう。

更新日:2021/04/28

書いた人(管理人):けーちゃん

スポンサーリンク

記事の内容について

null

こんにちはけーちゃんです。
説明するのって難しいですね。

「なんか言ってることおかしくない?」
たぶん、こんなご意見あると思います。

裏付けを取りながら記事を作成していますが、僕の勘違いだったり、そもそも情報源の内容が間違えていたりで、正確でないことが多いと思います。
そんなときは、ご意見もらえたら嬉しいです。

掲載コードについては事前に動作確認をしていますが、貼り付け後に体裁を整えるなどをした結果動作しないものになっていることがあります。
生暖かい視線でスルーするか、ご指摘ください。

ご意見、ご指摘はこちら。
https://note.affi-sapo-sv.com/info.php

 

このサイトは、リンクフリーです。大歓迎です。