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

JavaScript JavaScript
スポンサーリンク
スポンサーリンク

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

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

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

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

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


まず「何をしたいのか」で 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

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


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

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

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

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

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

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

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

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

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