JavaScriptでストップウォッチを作成(改訂版)

以前にも一度**[ストップウォッチを作成](https://qiita.com/akebi_mh/items/ce2f663ecdb4b6337319)**しましたが、ちょっと無駄な部分も多かったので一から作り直してみました。

機能的にはスタート/ストップ/再開/ラップタイムと前回とほとんど変わりませんが、動作状態やストップ時間保持用に独立したパラメータを使わずに済むよう計測方法を少し変更しています。

localStorageに動作状態を保存してリロードでの再開に対応しているのも前回と同様ですが、今回はラップタイム履歴も保持するようにしてみました。
保存したストレージデータは、計測リセット時に削除しています。

**[動作デモ](https://akebi.jp/temp/stopwatch_r2/)**
![test.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/618708/7d1649f8-aea0-5173-e8fc-bc192c2b80cf.jpeg)

**HTML**

```html:index.html
<!DOCTYPE html>
<html lang='ja'>
<head>
    <meta charset='utf-8'>
    <meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
    <title>stopwatch</title>
    <link rel='stylesheet' href='./style.css'>
    <script src='./script.js'></script>
</head>
<body>
    <div id='main'>
        <div id='display'></div>
        <button id='start'>START</button>
        <button id='reset'>RESET</button>
    </div>
    <div id='lap'></div>
</body>
</html>
```

**style**

```css:style.css
html {
    touch-action: manipulation;
    -webkit-touch-callout: none;
}
#main {
    position: sticky;
    top: 0px;
    background-color: #fff;
    padding-bottom: 12px;
}
#display {
    font-size: 22px;
    padding-bottom: 10px;
}
button {
    width: 48%;
    height: 50px;
    max-width: 120px;
    font-size: 20px;
    border-radius: 8px;
    border: 2px #888 solid;
    background-color: #fff;
    -webkit-appearance: none;
    -webkit-user-select: none;
    cursor: pointer;
}
#lap {
    font-size: 15px;
    margin-top: 20px;
}
```

**script**

```js:script.js
'use strict';
const
    storageName = 'stopWatch_status',
    elem        = {};

let
    startTime  = 0,
    lapTime    = 0,
    lapCount   = 0,
    lapRecords = [];

window.addEventListener('DOMContentLoaded', function() {
    ['start', 'reset', 'display', 'lap'].forEach(function(id) {
        elem[id] = document.getElementById(id)
    });

    // ボタンへのイベントリスナー追加
    const e = window.ontouchstart !== undefined ? 'touchstart' : 'mousedown';
    elem.start.addEventListener(e, clickStart);
    elem.reset.addEventListener(e, clickReset);

    timePrint(0, 0);
    const storage = getStorage();
    // localStorageにデータがあれば状態復元
    if(Object.keys(storage).length > 0) {
        startTime  = storage.startTime;
        lapTime    = storage.lapTime;
        lapRecords = storage.lapRecords;
        // 動作中
        if(startTime > 0) {
            elem.start.textContent = 'STOP';
            elem.reset.textContent = 'LAP';
            countUp();
        }
        // 一時停止中
        else if(startTime < 0) {
            timePrint(-startTime, -lapTime);
        }
        // ラップタイムレコード復元
        if(lapRecords.length > 0) {
            let str = '';
            for(let i in lapRecords) {
                str = '[' + (++lapCount) + '] ' + lapRecords[i] + '<br>' + str;
            }
            elem.lap.innerHTML = str;
        }
    }
});

// START/STOPボタン押下
function clickStart() {
    const now = Date.now();
    // 停止時
    if(startTime <= 0) {
        // 計測開始
        startTime += now;
        lapTime   += now;
        countUp();
        elem.start.textContent = 'STOP';
        elem.reset.textContent = 'LAP';
    }
    // 動作時
    else {
        // 一時停止
        startTime -= now;
        lapTime   -= now;
        timePrint(-startTime, -lapTime);
        elem.start.textContent = 'START';
        elem.reset.textContent = 'RESET';
    }
    setStorage();
}

// RESET/LAPボタン押下
function clickReset() {
    // 計測中
    if(startTime > 0) {
        // LAP
        const now = Date.now();
        timePrint(now - startTime, now - lapTime);
        lapTimePrint();
        lapTime = now;
        setStorage();
        window.scrollTo(0, 0);
    }
    // 停止中
    else {
        // リセット
        startTime = lapTime = 0;
        timePrint(0, 0);
        elem.lap.textContent = '';
        lapCount = 0;
        lapRecords = [];
        clearStorage();
    }
}

// 計測
function countUp() {
    if(startTime > 0) {
        const now = Date.now();
        timePrint(now - startTime, now - lapTime);
        requestAnimationFrame(countUp);
    }
}

// タイム表示
function timePrint(t, l) {
    elem.display.textContent = timeFormat(t) + ' / ' + timeFormat(l);
}

// 時間表示フォーマット
function timeFormat(t) {
    return Math.floor(t / 36e5) + new Date(t).toISOString().slice(13, 23);
}

// ラップタイムレコード追加
function lapTimePrint() {
    const str = display.textContent;
    lapRecords.push(str);
    elem.lap.innerHTML = '[' + (++lapCount) + '] ' + str + '<br>' + lap.innerHTML;
}

// localStorageデータ保存
function setStorage() {
    localStorage.setItem(storageName, JSON.stringify({
        startTime : startTime,
        lapTime   : lapTime,
        lapRecords: lapRecords,
    }));
}

// localStorageデータ削除
function clearStorage() {
    localStorage.removeItem(storageName);
}

// localStorageデータ取得
function getStorage() {
    const params = localStorage.getItem(storageName);
    return params ? JSON.parse(params) : {};
}
```

----

####基本的な計測動作の流れ####

**Date.now()**と、操作時の値を保持する変数**startTime**のみで計測しています。

初期状態では**startTime**の値は**0**です。
スタート時には**startTime**に**Date.now()**を代入します。
表示する際は**Date.now() - startTime**とすることで、スタートからの経過時間が得られます。

ストップ時には**startTime**から**Date.now()**を引きます。
結果的にスタートからの経過時間が負数で**startTime**に入りますので、**0 - startTime**とすると経過時間が得られます。

再開時には**startTime**に**Date.now()**を加算します。
既に**startTime**には止めた瞬間の経過時間が負数で入っているので、その差が**Date.now()**に対して反映された値が**startTime**に入ることになります。

こうすることで、**startTime**が**0以下**であれば停止中、**1以上**であれば動作中と判断できるため、動作状態を保持する変数を別に用意する必要もなくなります。

----

スタート、ストップ、再開、リセットのみでラップタイムや状態保持のない簡易バージョン。
**[動作デモ](https://akebi.jp/temp/sw_lite.html)**

```html
<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='user-scalable=no,width=device-width,initial-scale=1'>
<title>stopwatch lite</title>
</head>
<body>
<input type='text' id='view' readonly><br>
<button id='start'>START</button>
<button id='reset'>RESET</button>
<script>
'use strict';
const
    view     = document.getElementById('view'),
    startBtn = document.getElementById('start'),
    resetBtn = document.getElementById('reset');

let startTime = 0;

window.addEventListener('DOMContentLoaded', function() {
    const e = window.ontouchstart !== undefined ? 'touchstart' : 'mousedown';
    startBtn.addEventListener(e, clickStart);
    resetBtn.addEventListener(e, clickReset);
    view.value = timeFormat(0);
});

// STARTボタン押下時
function clickStart() {
    // 停止状態なら
    if(startTime <= 0) {
        // 計測開始
        startTime += Date.now();
        timePrint();
        startBtn.textContent = 'STOP';
        resetBtn.disabled = true;
    }
    // 計測中なら
    else {
        // 停止
        startTime -= Date.now();
        startBtn.textContent = 'START';
        resetBtn.disabled = false;
    }
}

// RESETボタン押下時
function clickReset() {
    // 停止状態なら
    if(startTime < 0) {
        // リセット
        startTime = 0;
        view.value = timeFormat(0);
    }
}

// 時間表示
function timePrint() {
    if(startTime > 0) {
        view.value = timeFormat(Date.now() - startTime);
        requestAnimationFrame(timePrint);
    }
}

// 表示フォーマット
function timeFormat(t) {
    return Math.floor(t / 36e5) + new Date(t).toISOString().slice(13, 23);
}
</script>
</body>
</html>
```