JavaScript | Web API:タイマー・スケジューリング - スケジューリング設計

JavaScript JavaScript
スポンサーリンク

「スケジューリング設計」は「いつ・どのくらい・どう止めるか」を決めること

タイマーは「ちょっと遅らせて実行する便利関数」ではなく、
「処理のタイミングを設計するための道具」 です。

いつ実行するか
どのくらいの頻度で実行するか
どのくらいの精度が必要か
いつ止めるか
UI や他の非同期処理とどう噛み合わせるか

これを考えずに setTimeoutsetInterval を置き始めると、
「なんかたまにバグる」「たまに二重実行される」「止まらない」みたいな、
じわじわ効いてくる不具合の温床になります。

ここでは、タイマーを「設計の道具」として扱う視点で整理していきます。


まず「何をしたいのか」で API を選ぶ

一回だけ遅らせたいのか、定期的に監視したいのか

一度だけ「あとで」実行したいなら setTimeout
定期的に同じ処理をしたいなら setInterval か、setTimeout の再帰。

ただし、設計としてはこう考えるとよいです。

一回だけの遅延実行
setTimeout が素直

定期的な処理
→ 原則として「再帰 setTimeout」を第一候補にする
setInterval は「処理が軽くて、多少ズレてもいいとき」に限定する

再帰 setTimeout のイメージはこれです。

function loop() {
  doSomething();
  setTimeout(loop, 1000);
}

loop();
JavaScript

これだと、

処理が終わってから 1 秒待つ
→ 次の実行

という流れになるので、
処理が多少重くても「詰まりにくい」設計になります。

UI を滑らかに動かしたいのか、裏方の処理なのか

UI を動かす・アニメーションするなら requestAnimationFrame
裏方で「今すぐじゃなくていい処理」なら requestIdleCallback

UI に関係するもの
requestAnimationFrame(描画タイミングと同期)

ログ送信や事前計算など裏方
requestIdleCallback(ブラウザがヒマなとき)

「ユーザーが今見ているもの」か
「ユーザーが気づかなくていい処理」かで、
使う API を切り替えるのがスケジューリング設計の第一歩です。


「止める設計」を最初から組み込む

タイマーは「開始」と「終了」をセットで設計する

タイマーを設計するときに、
一番やってはいけないのは「止め方を考えないこと」です。

例えば、ポーリング(定期的に API を叩く)を考えます。

setInterval(() => {
  fetch("/api/status");
}, 5000);
JavaScript

これだけだと、

ページを離れても
コンポーネントが破棄されても
永遠にリクエストを送り続けます。

設計としては、必ず「ライフサイクル」とセットにします。

let timerId = null;

function startPolling() {
  if (timerId !== null) return;

  timerId = setInterval(() => {
    fetch("/api/status");
  }, 5000);
}

function stopPolling() {
  if (timerId !== null) {
    clearInterval(timerId);
    timerId = null;
  }
}
JavaScript

「いつ始めるか」「いつ終わるか」を関数として切り出しておくと、
画面のマウント・アンマウント、イベントの開始・終了などに
きれいに紐づけられます。

終了条件をコードに埋め込む

「どんな条件で終わるべきか」を、
日本語で言えるようにしてからコードに落とします。

例えば「最大 3 回までリトライするポーリング」。

let retryCount = 0;
const maxRetry = 3;

function poll() {
  fetch("/api/status")
    .then((res) => {
      if (!res.ok) throw new Error("error");
      return res.json();
    })
    .then((data) => {
      console.log("成功", data);
    })
    .catch(() => {
      retryCount++;
      if (retryCount <= maxRetry) {
        setTimeout(poll, 1000);
      } else {
        console.log("リトライ上限に達したので終了");
      }
    });
}

poll();
JavaScript

ここでは、

成功したら終わる
失敗したら 1 秒後に再試行
ただし 3 回まで

という「終わり方」が明示されています。

スケジューリング設計では、
「どう始めるか」と同じくらい「どう終わるか」をコードに書くことが重要です。


精度と負荷をどうバランスさせるか

「ぴったり◯秒ごと」は基本的に無理だと知っておく

setTimeout(fn, 1000) と書いても、
きっちり 1000ms 後に実行されるとは限りません。

他の処理が重い
ブラウザがバックグラウンド
OS の制限

などで、平気でズレます。

「正確な時間」が必要なときは、
タイマーに頼らず「時計」を使います。

const start = Date.now();

function tick() {
  const elapsed = Date.now() - start;
  const seconds = Math.floor(elapsed / 1000);
  console.log(`${seconds} 秒経過`);

  if (seconds < 10) {
    setTimeout(tick, 200);
  } else {
    console.log("10秒経ったので終了");
  }
}

tick();
JavaScript

ここでは、

タイマーは「更新のきっかけ」
正確な時間は Date.now() で計算

という役割分担になっています。

負荷を下げるために「間隔」と「分割」を設計する

例えば、重い処理を 1 万件分やりたいとき、
一気にループすると UI が固まります。

スケジューリング設計としては、
「小分けにして、タイマーで分散する」という発想を取ります。

const items = Array.from({ length: 10000 }, (_, i) => i);
let index = 0;

function workChunk() {
  const start = Date.now();

  while (index < items.length && Date.now() - start < 10) {
    const item = items[index];
    // item を処理
    index++;
  }

  if (index < items.length) {
    setTimeout(workChunk, 0);
  } else {
    console.log("完了");
  }
}

workChunk();
JavaScript

ここでは、

1 回の処理時間を 10ms 以内に抑える
残りは次のタイミングに回す

という「負荷の上限」を設計しています。

requestIdleCallback を使えば、
「ブラウザがヒマなときにだけ進める」という設計もできます。


非同期処理(Promise / async/await)との組み合わせ方

タイマーを Promise 化して「待てるもの」にする

setTimeout はコールバック型ですが、
設計としては Promise に包んでしまうと扱いやすくなります。

function wait(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

async function run() {
  console.log("1");
  await wait(1000);
  console.log("2");
  await wait(1000);
  console.log("3");
}

run();
JavaScript

こうすると、

処理の流れが読みやすい
他の非同期処理(fetch など)と同じ文脈で扱える
テストもしやすい

というメリットがあります。

スケジューリング設計の観点では、
「タイミング制御を async/await の世界に持ち込む」ことで、
コード全体の見通しが良くなります。

「待ち時間」も設計の一部にする(バックオフ戦略)

API のリトライなどでは、
毎回同じ間隔で叩くのではなく、
徐々に間隔を伸ばす「エクスポネンシャルバックオフ」がよく使われます。

async function fetchWithRetry(url, maxRetry = 5) {
  let retry = 0;
  let delay = 500;

  while (retry <= maxRetry) {
    try {
      const res = await fetch(url);
      if (!res.ok) throw new Error("HTTP error");
      return await res.json();
    } catch (e) {
      if (retry === maxRetry) throw e;

      await wait(delay);
      delay *= 2;
      retry++;
    }
  }
}
JavaScript

ここでは、

失敗するたびに待ち時間を伸ばす
→ サーバーへの負荷を抑える
→ ネットワークの一時的な不調にも優しい

という「時間の設計」をしています。

スケジューリング設計は、
「どのくらいの頻度で外部と話すか」を決めることでもあります。


UI とスケジューリングを一緒に設計する

UI 更新は requestAnimationFrame、裏方は setTimeout / requestIdleCallback

例えば、チャットアプリの新着メッセージ表示を考えます。

新着を取りに行くポーリング
setTimeout の再帰で数秒ごとに実行
→ バックオフ戦略を入れてもよい

メッセージのフェードインやスクロールアニメーション
requestAnimationFrame で描画タイミングに合わせる

ログ送信や解析
requestIdleCallback でヒマなときに送る

このように、

「ユーザーが体感する部分」
「裏でこっそりやる部分」

を分けて、それぞれに合ったスケジューリングを選ぶのが、
UI を気持ちよく保つ設計です。

無限ループ・二重起動を防ぐための状態管理

タイマー設計でよくある事故が、

同じタイマーを何度も開始してしまう
止めるべきタイミングで止めていない

というものです。

これを防ぐには、
「状態」を変数として持つのが基本です。

let timerId = null;
let running = false;

function start() {
  if (running) return;
  running = true;

  timerId = setInterval(() => {
    console.log("tick");
  }, 1000);
}

function stop() {
  if (!running) return;
  running = false;

  clearInterval(timerId);
  timerId = null;
}
JavaScript

「今動いているかどうか」を
runningtimerId で表現しておくと、
スケジューリングのバグが一気に減ります。


まとめとしての「スケジューリング設計のチェックリスト的な感覚」

一回だけか、繰り返しか
setTimeout か、再帰 setTimeout か、setInterval

UI に関係するか、裏方か
requestAnimationFrame か、requestIdleCallback

いつ始めて、いつ終わるか
→ 開始・終了関数を用意して、ライフサイクルに結びつける

どのくらいの精度が必要か
→ タイマーに頼るのか、Date.now() / performance.now() で補正するのか

負荷をどう抑えるか
→ 間隔・分割・バックオフを設計する

ここまで意識してタイマーを書くようになると、
「とりあえず setTimeout 置いとくか」という世界から一段抜けて、
「時間を設計できるプログラマ」 になっていきます。

もしよければ、今書いているコードの中で
「なんとなく setInterval を置いているところ」
「止め方を考えていないタイマー」
を一つ見つけて、
今日の話をもとに設計し直してみてください。
そこから一気に感覚が変わります。

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