概要
JavaScriptには、処理の実行タイミングを制御するタイマAPIが複数存在する。
主要なタイマ機構として、setTimeout、setInterval、requestAnimationFrame の3つがある。
setTimeout は指定した遅延時間後に処理を1回実行するタイマである。
setInterval は指定した間隔で処理を繰り返し実行するタイマである。
requestAnimationFrame はブラウザの描画タイミングに同期してアニメーションを実行するためのAPIである。
これらのタイマはJavaScriptのイベントループと密接に関連している。
setTimeout と setInterval はマクロタスクキューに投入され、現在の実行コンテキストとマイクロタスク (Promise等) が完了した後に実行される。
requestAnimationFrame はWebブラウザの再描画前に実行されるため、スムーズなアニメーション実装に適している。
また、頻繁に発生するイベントの処理最適化手法として、デバウンスとスロットルがある。
これらはタイマAPIを活用した応用パターンである。
Reactにおいては、コンポーネントのライフサイクルと連動させるために useEffect 内でタイマを管理し、クリーンアップ関数でタイマを解除することが重要である。
setTimeout / clearTimeout
setTimeout は、指定した遅延時間 (ミリ秒) の経過後に、コールバック関数を1回だけ実行するタイマ関数である。
実行をキャンセルするには clearTimeout を使用する。
基本的な使い方
基本構文を以下に示す。
const timeoutID = setTimeout(callback, delay, arg1, arg2, ...);
clearTimeout(timeoutID);
引数の詳細を以下に示す。
| 引数 | 説明 |
|---|---|
| callback | 遅延後に実行するコールバック関数 |
| delay | 遅延時間 (ミリ秒) 省略時は最速で実行される。 |
| Webブラウザの最小遅延時間は約4[ms] (ネストが深い場合) | |
| arg1, arg2, ... | コールバック関数に渡す追加の引数 (省略可能) |
コールバックへ引数を渡す使用例を以下に示す。
setTimeout((name, age) => {
console.log(`Hello ${name}, you are ${age} years old`);
}, 1000, "Alice", 30);
戻り値 (タイマID) とクリア
setTimeout は正の整数値であるタイマID (timeoutID) を返す。
このIDは同一グローバルスコープ内で一意に識別され、clearTimeout によるキャンセルに使用する。
タイマのキャンセル例を以下に示す。
let timerId = setTimeout(() => {
console.log("This will run after 2 seconds");
}, 2000);
// タイマをキャンセル
clearTimeout(timerId);
タイマIDに関する仕様を以下に示す。
setTimeoutとsetIntervalはID共有プール内でIDを生成するため、互いに異なるIDが割り当てられるclearTimeoutに存在しないIDを渡した場合は何もしない (エラーにならない)- タイマIDは変数に保存しておくことで、後からキャンセルできる
0ms指定とイベントループ
setTimeout(callback, 0) を指定しても、コールバックは即座には実行されない。
これはJavaScriptのイベントループの仕組みによるものである。
イベントループの処理順序を以下に示す。
- コールスタック内のコードを実行する。
- マイクロタスクキューを全て処理する。(
Promise.then()、queueMicrotask()等) - 必要な場合、DOMを再描画する。
- マクロタスクキューから1つのタスクを実行する。(
setTimeout、setInterval、I/O 等)
setTimeout はマクロタスクキューに投入されるため、現在のスクリプトと全てのマイクロタスクが完了してから実行される。
実行順序の確認例を以下に示す。
console.log('1. Sync');
setTimeout(() => {
console.log('5. Macro task (setTimeout)');
}, 0);
Promise.resolve()
.then(() => {
console.log('3. Micro task (Promise)');
});
queueMicrotask(() => {
console.log('4. Micro task (queueMicrotask)');
});
console.log('2. Sync');
// 出力:
// 1. Sync
// 2. Sync
// 3. Micro task (Promise)
// 4. Micro task (queueMicrotask)
// 5. Macro task (setTimeout)
この特性を利用することで、重い処理をメインスレッドをブロックせずに分割して実行できる。
応用例を以下に示す。
async function processLargeDataset(data) {
const chunkSize = 100;
for (let i = 0; i < data.length; i += chunkSize) {
const chunk = data.slice(i, i + chunkSize);
processChunk(chunk);
// マクロタスクに制御を返すことで、UIの描画をブロックしない
await new Promise(resolve => setTimeout(resolve, 0));
}
}
setInterval / clearInterval
setInterval は、指定した間隔 (ミリ秒) ごとにコールバック関数を繰り返し実行するタイマ関数である。
繰り返し実行を停止するには、clearInterval を使用する。
基本的な使い方
基本構文を以下に示す。
const intervalID = setInterval(callback, delay, arg1, arg2, ...);
clearInterval(intervalID);
引数の仕様は、setTimeout と同じである。
戻り値は、intervalID で、clearInterval で繰り返し実行をキャンセルする。
使用例を以下に示す。
let count = 0;
const intervalID = setInterval(() => {
console.log(`Count: ${count++}`);
}, 1000);
// 5秒後にタイマを停止
setTimeout(() => clearInterval(intervalID), 5000);
注意点 (ドリフト、重複実行)
setInterval には以下に示すような問題点がある。
| 問題点 | 説明 |
|---|---|
| タイマドリフト | メインスレッドがブロックされた場合、実行タイミングがずれる。 |
| コールバックの処理時間がインターバルより長い場合、実行タイミングが遅延する。 | |
| 重複実行 | 前のコールバックの実行が完了する前に、次の実行がスケジュールされる可能性がある。 |
| 非同期処理を含む場合に複数のコールバックが並行実行される危険がある。 | |
| メモリリーク | clearInterval を忘れると、バックグラウンドで永続的に実行され続ける。
|
| 不柔軟な間隔 | コールバックの実行時間を考慮せず、固定間隔でスケジュールする。 |
重複実行が問題になるケースを以下に示す。
// 問題のあるパターン : fetchDataが3秒掛かる場合
setInterval(async () => {
const data = await fetchData(); // 3秒掛かる
console.log(data);
}, 2000); // 2秒ごとに呼び出すため、複数が並行実行される
setTimeoutによる代替パターン
setInterval の問題を回避するために、再帰的な setTimeout を使用するパターンが推奨される。
このパターンでは、前の実行が完了した後に次の実行をスケジュールするため、重複実行が発生しない。
- 代替パターン1 : 再帰的
setTimeoutを以下に示す。async function repeatTask() { try { const data = await fetchData(); console.log(data); } catch (error) { console.error(error); } // 前の実行が完了した後に次の実行をスケジュール setTimeout(repeatTask, 2000); } repeatTask();
- 代替パターン2 : キャンセル可能な
async/awaitループを以下に示す。class CancellableTask { constructor() { this.cancelled = false; } async start() { while (!this.cancelled) { try { await fetchData(); } catch (error) { console.error(error); } await new Promise(resolve => setTimeout(resolve, 2000)); } } cancel() { this.cancelled = true; } }
requestAnimationFrame
requestAnimationFrame は、Webブラウザの再描画タイミングに同期してコールバックを実行するAPIである。
スムーズなアニメーションの実装に使用され、setTimeout や setInterval よりもアニメーションに適している。
基本的な使い方
基本構文を以下に示す。
const requestID = requestAnimationFrame(callback);
cancelAnimationFrame(requestID);
requestAnimationFrame(callback)はコールバック関数を登録して、requestIDを返す。- コールバックは次の再描画前に1回だけ実行される。(ワンショット)
cancelAnimationFrame(requestID)で登録をキャンセルする。- アニメーションループを継続するには、コールバック内で再度
requestAnimationFrameを呼び出す。
特性 (リフレッシュレート同期、非アクティブタブでの一時停止)
requestAnimationFrame の特性を以下に示す。
リフレッシュレートとの同期
Webブラウザはディスプレイのリフレッシュレートに合わせてコールバックを呼び出す。
| リフレッシュレート | 実行間隔 (近似値) |
|---|---|
| 60[Hz] | 約16.67[ms]ごと |
| 120[Hz] | 約8.33[ms]ごと |
| 144[Hz] | 約6.94[ms]ごと |
非アクティブタブでの一時停止
タブがバックグラウンドに移動した場合、requestAnimationFrame は自動的に一時停止する。
この特性により、以下のメリットがある。
- CPU使用率の削減
- バッテリー消費の低減
- 不要な描画処理の抑制
対比として、setTimeout と setInterval はバックグラウンドタブでも実行され続ける。
(Webブラウザによっては1秒以上の遅延が加えられる場合がある)
timestampパラメータ
コールバック関数はWebブラウザ起動からの経過時間 (ミリ秒) を timestamp パラメータとして受け取る。
このパラメータを使用することにより、フレームレートに依存しない時間ベースのアニメーションを定義できる。
アニメーションループの実装
requestAnimationFrame を使用した時間ベースのアニメーションループの実装例を以下に示す。
let startTime = null;
const duration = 3000; // アニメーション時間: 3秒
function animateWithDuration(currentTime) {
if (startTime === null) startTime = currentTime;
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
// progressに基づいて要素のスタイルを変更 (0 -> 1)
element.style.opacity = progress;
// アニメーションが完了していなければ次のフレームを要求
if (progress < 1) {
requestAnimationFrame(animateWithDuration);
}
}
requestAnimationFrame(animateWithDuration);
デバウンスとスロットル
デバウンスとスロットルは、頻繁に発生するイベントの処理を最適化するためのパターンである。
どちらも setTimeout を活用して実装される。
デバウンス
デバウンスは、イベントが連続で発生する場合に、最後のイベントから指定時間経過後に初めて処理を実行するパターンである。
ユーザの操作が完了したことを確認してから処理を実行する場合に有効である。
主な使用例を以下に示す。
| 使用例 | 説明 |
|---|---|
| 検索入力 | ユーザが入力を止めた後にAPIを呼び出す。 |
| フォーム検証 | 入力完了後に検証を実行する。 |
| ウィンドウリサイズ | リサイズ完了後にレイアウトを更新する。 |
デバウンスの実装例を以下に示す。
function debounce(fn, delay) {
let timeoutId = null;
return function(...args) {
// 前のタイマをキャンセル
if (timeoutId) clearTimeout(timeoutId);
// 新しいタイマを設定
timeoutId = setTimeout(() => {
fn.apply(this, args);
timeoutId = null;
}, delay);
};
}
const handleSearch = debounce(async (e) => {
const query = e.target.value;
// APIを呼び出す (入力停止から500[ms]後に実行)
}, 500);
searchInput.addEventListener('input', handleSearch);
スロットル
スロットルは、一定時間ごとに最大1回だけ処理を実行するパターンである。
定期的な監視が必要 かつ 処理頻度を制限したい場合に有効である。
下表に、主な使用例を示す。
| 使用例 | 説明 |
|---|---|
| スクロールイベント | スクロール位置の監視 |
| マウス移動 | マウス座標の追跡 |
| ゲームのキー入力 | 一定間隔でのアクション実行 |
スロットルの実装例を以下に示す。
function throttle(fn, interval) {
let lastTime = 0;
return function(...args) {
const now = Date.now();
// 前回の実行から指定間隔が経過した場合のみ実行
if (now - lastTime >= interval) {
lastTime = now;
fn.apply(this, args);
}
};
}
const handleScroll = throttle(() => {
console.log(`Scroll: ${window.scrollY}`);
}, 200);
window.addEventListener('scroll', handleScroll);
下表に、デバウンスとスロットルの比較を示す。
| 特性 | デバウンス | スロットル |
|---|---|---|
| 実行タイミング | 最後のイベント後、指定時間経過後 | 指定時間ごとに最大1回 |
| 主な使用例 | 検索入力、フォーム検証 | スクロール、マウス移動 |
| 処理の遅延 | 入力停止まで遅延 | 固定間隔で定期実行 |
| ユースケース | 最終的な値が必要 | 定期的な監視が必要 |
ReactのuseEffect内でのタイマ管理
Reactコンポーネント内でタイマを使用する場合、コンポーネントのライフサイクルとタイマを適切に連動させる必要がある。
コンポーネントがアンマウントされる時にタイマが解除されないと、メモリリークやアンマウント後のstate更新による警告が発生する。
クリーンアップ関数の重要性
useEffect はクリーンアップ関数を返すことができる。
このクリーンアップ関数は、コンポーネントのアンマウント時や依存配列の値が変更される前に実行される。
クリーンアップが必要な理由を以下に示す。
- コンポーネントがアンマウント後もタイマが動作し続け、存在しないstateを更新しようとするとエラーになる。
setIntervalの場合、クリーンアップを忘れると、アンマウント後もコールバックが永続的に実行される。- メモリリークによりアプリケーションのパフォーマンスが低下する。
setTimeout / setInterval の管理
setTimeoutをuseEffect内で使用する例import { useEffect } from 'react'; function DelayedComponent() { useEffect(() => { const timerId = setTimeout(() => { console.log("Timeout executed"); }, 1000); // クリーンアップ関数でタイマをキャンセル return () => clearTimeout(timerId); }, []); return <div>Delayed component</div>; }
setIntervalをuseEffect内で使用する例import { useEffect, useState } from 'react'; function CounterComponent() { const [count, setCount] = useState(0); useEffect(() => { const intervalId = setInterval(() => { // 関数形式で更新することで、依存配列にcountを含めずに済む setCount(c => c + 1); }, 1000); // クリーンアップ関数でインターバルをキャンセル return () => clearInterval(intervalId); }, []); return <div>Count: {count}</div>; }
setCount(c => c + 1) のように関数形式で更新することにより、count を依存配列に含める必要がなくなり、不要なインターバルの再登録を防ぐことができる。
requestAnimationFrame の管理
requestAnimationFrame をReactで使用する場合、useEffect の代わりに useLayoutEffect を使用することが推奨される。
useLayoutEffect はDOM更新後、再描画前に同期的に実行されるため、アニメーションのタイミング問題を回避できる。
useLayoutEffect と requestAnimationFrame を組み合わせた例を以下に示す。
import { useLayoutEffect, useRef } from 'react';
function AnimationComponent() {
const elementRef = useRef(null);
useLayoutEffect(() => {
let animationId = null;
let x = 0;
function animate() {
x += 2;
if (elementRef.current) {
elementRef.current.style.left = x + 'px';
}
if (x < 500) {
animationId = requestAnimationFrame(animate);
}
}
animationId = requestAnimationFrame(animate);
// クリーンアップ関数でアニメーションをキャンセル
return () => {
if (animationId) cancelAnimationFrame(animationId);
};
}, []);
return <div ref={elementRef} style={{ position: 'absolute' }}>Animated element</div>;
}
useLayoutEffect を使用する場合の注意点を以下に示す。
useLayoutEffectはサーバサイドレンダリング (SSR) では実行されないため、SSR環境ではuseEffectを使用するか、条件分岐が必要- クライアントサイドのみで動作するアニメーションには
useLayoutEffectが適している。
関連情報
- MDN Web Docs - setTimeout
- MDN Web Docs - clearTimeout
- MDN Web Docs - setInterval
- MDN Web Docs - clearInterval
- MDN Web Docs - requestAnimationFrame
- MDN Web Docs - マイクロタスクガイド
- React 公式ドキュメント - useEffect
- React 公式ドキュメント - useLayoutEffect