setTimeout を setInterval で書き直す:非同期処理の再考と実装戦略
フロントエンド開発において、非同期処理の制御は避けて通れない重要なトピックです。特に「一定時間後に一度だけ実行する」ための `setTimeout` と、「一定時間ごとに繰り返し実行する」ための `setInterval` は、JavaScriptのタイマー関数の双璧を成しています。
しかし、実務の現場では「なぜあえて setInterval を使って setTimeout のような挙動を再現するのか?」という問いに直面することがあります。あるいは、逆に「再帰的な setTimeout を用いて setInterval を代替する」という設計パターンが必要になるケースも少なくありません。本記事では、これら二つのタイマー関数の本質的な違いを紐解き、なぜ後者が堅牢なアプリケーション設計において推奨されるのかを詳細に解説します。
タイマー関数のメカニズムと非同期キューの理解
JavaScriptはシングルスレッドで動作する言語であり、タイマー関数は「タスクキュー」という仕組みを利用して実行タイミングを制御します。
`setTimeout(callback, delay)` は、指定された時間が経過した後に、コールバック関数をタスクキューの末尾に追加します。一方、`setInterval(callback, delay)` は、指定された間隔ごとにコールバックをタスクキューへ追加し続けます。
ここで重要なのは、JavaScriptのイベントループの挙動です。もしコールバック関数の実行に時間がかかり、次のタイマーの実行タイミングが重なった場合、`setInterval` は実行の完了を待たずにキューへ関数を追加しようとします。これにより、処理の重なりによる「実行のスキップ」や「予期せぬ順序の崩れ」が発生するリスクがあるのです。
setInterval の限界と再帰的 setTimeout の優位性
`setInterval` を使用する際、最も懸念すべきは「実行時間が間隔(delay)を超えた場合」です。例えば、100ms ごとに重いデータ処理を行う関数を `setInterval` で呼び出したとします。もしその処理に150msかかると、実行完了を待たずに次の実行がキューイングされ、結果として処理がスタックしたり、UIのレスポンスが著しく低下したりします。
これを解決する最もエレガントな手法が「再帰的な setTimeout」です。これは、処理の最後で次のタイマーをセットする手法です。
このアプローチの利点は明確です。処理が完了するまで次のタイマーがセットされないため、必ず「処理の完了」と「次の開始」の間に一定のバッファが確保されます。これにより、処理の重複を防ぎ、CPU負荷を一定に保つことが可能になります。
サンプルコード:再帰的 setTimeout による安全なループ実装
以下に、実務で頻繁に使用される「再帰的 setTimeout」のパターンを示します。これは `setInterval` の挙動を安全にシミュレートしつつ、処理の重複を回避する設計です。
/**
* 再帰的 setTimeout を用いた安全なタイマー処理
* @param {Function} task - 実行する非同期または同期処理
* @param {number} interval - 実行間隔(ms)
*/
function safeInterval(task, interval) {
let timerId;
const loop = async () => {
try {
// タスクの実行を待機することで、処理の重複を防ぐ
await task();
} catch (error) {
console.error('タスク実行中にエラーが発生しました:', error);
} finally {
// 処理が完了した後に次のタイマーをセットする
timerId = setTimeout(loop, interval);
}
};
// 初回実行
timerId = setTimeout(loop, interval);
// 停止用のインターフェースを返す
return {
stop: () => clearTimeout(timerId)
};
}
// 使用例
const myTask = async () => {
console.log('タスクを開始:', new Date().toISOString());
// 重い処理を想定した遅延
await new Promise(resolve => setTimeout(resolve, 500));
console.log('タスクを終了:', new Date().toISOString());
};
const timer = safeInterval(myTask, 1000);
// 5秒後に停止
setTimeout(() => {
timer.stop();
console.log('タイマーを停止しました');
}, 5000);
実務における設計のアドバイス
実務の現場では、単にタイマーを動かすだけでなく、以下の観点を意識することがプロフェッショナルとしての品質に繋がります。
1. 視認性と可読性:
`setInterval` はコードが短く済みますが、エラーハンドリングが困難です。再帰的 `setTimeout` を使用することで、`try-catch` ブロックを容易に組み込めるようになり、タスクの失敗がループ全体を止めてしまうリスクを制御できます。
2. メモリリークの防止:
コンポーネントのアンマウント時やページ遷移時にタイマーを確実にクリアすることは必須です。`setInterval` は ID を管理するだけで済みますが、複雑な非同期処理を扱う場合は、停止フラグを用いた制御を行うなど、状態管理を明確にしましょう。
3. 実行タイミングの保証:
高頻度な更新(例えば 16ms 周期の描画ループなど)が必要な場合は、`requestAnimationFrame` を選択すべきです。`setTimeout` や `setInterval` は、あくまで「おおよその時間」を保証するものであり、正確なタイミングを保証するものではないという前提を忘れてはいけません。
4. 依存関係の注入:
テストを容易にするために、タイマーの処理をカスタムフックやクラスにカプセル化することを推奨します。特に React を使用している場合、`useEffect` 内で `setInterval` を直接使うと、クロージャの罠に陥りやすいため、`useRef` を活用した再帰的アプローチは非常に有効です。
なぜ setInterval ではなく setTimeout を選ぶべきなのか
最終的に、なぜ `setInterval` ではなく再帰的な `setTimeout` なのかという問いに対する答えは「制御の主導権」にあります。
`setInterval` は、一度開始するとJavaScriptエンジンが自動的に処理を続行します。この「自動化」は簡便な反面、システムが過負荷になった際に制御不能な状態を招くリスクを孕んでいます。一方、再帰的な `setTimeout` は、常に「次のループをいつ開始するか」を開発者が明示的に判断する余地を残します。
現代のフロントエンド開発において、アプリケーションは複雑化しており、単なるタイマー処理であっても「非同期処理の完了を待機する」という要件は必須です。処理の完了を待たずに次の処理を重ねてしまう `setInterval` の仕様は、モダンなアプリケーションの要件と乖離しつつあると言わざるを得ません。
まとめ
本記事では、`setInterval` の本質的なリスクと、それを再帰的な `setTimeout` で解決する手法について解説しました。
– `setInterval` は処理の重複を防ぐ仕組みを持たないため、重い処理には不向きである。
– 再帰的な `setTimeout` は、処理の完了を待機してから次のタイマーを予約するため、実行の堅牢性が高い。
– 非同期処理(async/await)との親和性は、圧倒的に再帰的 `setTimeout` が優れている。
– 実務ではエラーハンドリングやタイマー停止のライフサイクルを考慮した設計が不可欠である。
タイマー関数という、JavaScriptの最も基本的なAPIであっても、その挙動を深く理解し、適切なパターンを選択することで、アプリケーションの信頼性は劇的に向上します。ぜひ、あなたのプロジェクトでも、単純な `setInterval` から、より制御可能な設計への移行を検討してみてください。この小さな選択が、将来的なバグの温床を一つ減らし、より堅牢なプロダクトへと繋がるはずです。

コメント