はじめに
第2回では、setIntervalで「1秒ごとに1減らす」タイマーを作った。
しかし、このタイマーは処理が詰まったり、タブが裏に回ったりすると、実際の時間より遅れてしまうことが確かめられた。
第3回では、このずれを解消する。考え方を変えて、終了予定の時刻と、今の時刻の差から、残り時間を計算する。そのために、Date.now()とuseRefを学ぶ。
今回やること
- ずれの原因と、解決の考え方を知る
Date.now()で、現在の時刻を取得するuseRefで、画面に表示しない値を持つ- 残り時間を、ミリ秒で扱うようにする
- ずれないことを、もう一度実験で確かめる
画面の見た目は、第2回と変わらない。変わるのは、中身の計算方法である。
実行環境
第1・2回と同じである。ソースコードは、次の筆者のリポジトリの03_accurate-timerフォルダにある。第2回の完成コードに、今回の変更を加えたものである。
cd 03_accurate-timer
npm install
npm run devずれない方法を考える
第2回の方式は、「1秒たった」と数える方式だった。
setRemainingSeconds((prev) => prev - 1);この方式は、1回でも数え損なうと、その分が戻らない。処理が詰まって実行が遅れても、「1」しか減らないので、遅れが積み重なっていく。
そこで、数えるのをやめる。代わりに、次のように考える。
- 開始した時に、「いつ終わるか(終了予定の時刻)」を決めておく
- 確かめる時は、「終了予定の時刻 − 今の時刻」を計算する
これなら、確かめる処理が遅れても、遅れた時点で正しい残り時間が求まる。数え損なう、ということがない。時計を見て「あと何分か」を判断するのと、同じ考え方である。
Date.now()
時刻を扱うには、Date.now()を使う。入門編の第2回で、new Date(...)を学んだが、Date.now()は、もっと簡単である。
const now = Date.now();
console.log(now); // 例:1760000000000Date.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との違いは、次のとおりである。
useState | useRef | |
|---|---|---|
| 値を変えると | 画面が再描画される | 画面は再描画されない |
| 再描画しても、値は | 保持される | 保持される |
| 使う場面 | 画面に表示する値 | 画面に表示しない値 |
終了予定の時刻は、画面に表示しない。表示するのは、「残り時間」である。そのため、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回と同じ実験を、もう一度行う。
ブラウザのタブでタイマーを開き、スマートフォンなどのストップウォッチを用意する。
- 科目を入力して、タイマーの「開始」ボタンを押す。同時に、ストップウォッチも開始する
- タイマーとストップウォッチは、止めずに動かしたまま、タイマーを表示しているそのタブで、
F12キーを押して、開発者ツール(DevTools)を開く - 「コンソール」のタブを選び、
>が表示された入力欄をクリックする - 次のコードを貼り付けて、
Enterキーを押す。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でなめらかに動かす。今回、残り時間をミリ秒で持つようにしたことが、そこで活きてくる。


