よくあるデバッグシナリオ
RxJS開発で遭遇する典型的な問題とその解決方法を、具体的なコード例とともに解説します。
シナリオ1: 値が流れてこない
- 症状:
subscribeしているのに、値が一つも出力されない
原因1: Cold Observable の購読忘れ
ts
import { interval } from 'rxjs';
import { map } from 'rxjs';
// ❌ 購読していないため、何も実行されない
const numbers$ = interval(1000).pipe(
map(x => {
console.log('この行は実行されない');
return x * 2;
})
);
// ✅ 購読することで実行される
numbers$.subscribe(value => console.log('値:', value));原因2: 完了済みの Subject
ts
import { Subject } from 'rxjs';
const subject = new Subject<number>();
subject.complete(); // 完了
// ❌ 完了後の購読では値を受け取れない
subject.subscribe(value => console.log('この行は実行されない'));
// ✅ 完了前に購読する
const subject2 = new Subject<number>();
subject2.subscribe(value => console.log('値:', value));
subject2.next(1); // 値: 1
subject2.complete();原因3: 誤った条件でのフィルタリング
ts
import { of } from 'rxjs';
import { filter, tap } from 'rxjs';
of(1, 2, 3, 4, 5)
.pipe(
tap(value => console.log('filter前:', value)),
filter(x => x > 10), // すべて除外されてしまう
tap(value => console.log('filter後:', value)) // この行は実行されない
)
.subscribe({
next: value => console.log('最終値:', value),
complete: () => console.log('完了(値なし)')
});
// 出力:
// filter前: 1
// filter前: 2
// filter前: 3
// filter前: 4
// filter前: 5
// 完了(値なし)デバッグ手法
ts
import { of, EMPTY } from 'rxjs';
import { filter, tap, defaultIfEmpty } from 'rxjs';
of(1, 2, 3, 4, 5)
.pipe(
tap(value => console.log('🔵 入力:', value)),
filter(x => x > 10),
tap(value => console.log('🟢 filter通過:', value)),
defaultIfEmpty('値なし') // 値がない場合のデフォルト
)
.subscribe(value => console.log('✅ 出力:', value));
// 出力:
// 🔵 入力: 1
// 🔵 入力: 2
// 🔵 入力: 3
// 🔵 入力: 4
// 🔵 入力: 5
// ✅ 出力: 値なしシナリオ2: 期待と異なる値が出力される
- 症状: 想定していた値とは異なる値が出力される
原因1: オペレーターの順序が誤っている
ts
import { of } from 'rxjs';
import { map, filter } from 'rxjs';
// ❌ 期待と異なる結果
of(1, 2, 3, 4, 5)
.pipe(
map(x => x * 2), // 2, 4, 6, 8, 10
filter(x => x < 5) // 2, 4 のみ通過
)
.subscribe(value => console.log('結果:', value));
// 出力: 2, 4
// ✅ 正しい順序
of(1, 2, 3, 4, 5)
.pipe(
filter(x => x < 5), // 1, 2, 3, 4 のみ通過
map(x => x * 2) // 2, 4, 6, 8
)
.subscribe(value => console.log('結果:', value));
// 出力: 2, 4, 6, 8原因2: 参照の共有による意図しない変更
ts
import { of } from 'rxjs';
import { map } from 'rxjs';
interface User {
id: number;
name: string;
}
const user: User = { id: 1, name: 'Alice' };
of(user)
.pipe(
// ❌ 元のオブジェクトを直接変更してしまう
map(u => {
u.name = 'Bob'; // 元のオブジェクトが変更される
return u;
})
)
.subscribe(value => console.log('変更後:', value));
console.log('元のオブジェクト:', user); // { id: 1, name: 'Bob' }
// ✅ 新しいオブジェクトを作成する
of(user)
.pipe(
map(u => ({ ...u, name: 'Charlie' })) // スプレッド構文で新しいオブジェクト
)
.subscribe(value => console.log('変更後:', value));
console.log('元のオブジェクト:', user); // { id: 1, name: 'Alice' }(変更されない)原因3: 非同期処理のタイミング
ts
import { of, delay } from 'rxjs';
import { mergeMap, tap } from 'rxjs';
// ❌ 非同期処理の完了を待たない
of(1, 2, 3)
.pipe(
tap(value => console.log('開始:', value)),
mergeMap(value =>
of(value * 2).pipe(
delay(100 - value * 10) // 値が大きいほど早く完了
)
)
)
.subscribe(value => console.log('完了:', value));
// 出力:
// 開始: 1
// 開始: 2
// 開始: 3
// 完了: 3 ← 遅延が最も短い
// 完了: 2
// 完了: 1 ← 遅延が最も長い
// ✅ 順序を保証する
import { concatMap } from 'rxjs';
of(1, 2, 3)
.pipe(
tap(value => console.log('開始:', value)),
concatMap(value => // mergeMap → concatMap
of(value * 2).pipe(delay(100 - value * 10))
)
)
.subscribe(value => console.log('完了:', value));
// 出力:
// 開始: 1
// 完了: 1
// 開始: 2
// 完了: 2
// 開始: 3
// 完了: 3シナリオ3: 購読が完了しない(無限ストリーム)
- 症状:
completeが呼ばれず、ストリームが終了しない
ts
import { interval } from 'rxjs';
import { tap } from 'rxjs';
// ❌ interval は無限に値を発行し続ける
interval(1000)
.pipe(
tap(value => console.log('値:', value))
)
.subscribe({
complete: () => console.log('この行は実行されない')
});
// ✅ take で明示的に完了させる
import { take } from 'rxjs';
interval(1000)
.pipe(
take(5), // 5個の値の後に完了
tap(value => console.log('値:', value))
)
.subscribe({
complete: () => console.log('完了')
});デバッグ手法
ts
import { interval, timer } from 'rxjs';
import { tap, takeUntil } from 'rxjs';
// タイムアウトを設定してデバッグ
const stop$ = timer(5000); // 5秒後に完了
interval(1000)
.pipe(
takeUntil(stop$),
tap({
next: value => console.log('値:', value),
complete: () => console.log('タイムアウトで停止')
})
)
.subscribe();シナリオ4: メモリリーク(購読解除忘れ)
- 症状: アプリケーションの動作が徐々に重くなる
原因: 不要になった購読を解除していない
ts
import { interval } from 'rxjs';
class UserComponent {
private subscription: any;
ngOnInit() {
// ❌ 購読解除を忘れている
interval(1000).subscribe(value => {
console.log('値:', value); // コンポーネント破棄後も実行され続ける
});
}
ngOnDestroy() {
// 購読解除が行われない
}
}
// ✅ 購読を適切に管理
class UserComponentFixed {
private subscription: any;
ngOnInit() {
this.subscription = interval(1000).subscribe(value => {
console.log('値:', value);
});
}
ngOnDestroy() {
// コンポーネント破棄時に購読解除
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}推奨パターン: takeUntil を使用
ts
import { interval, Subject } from 'rxjs';
import { takeUntil } from 'rxjs';
class UserComponentBest {
private destroy$ = new Subject<void>();
ngOnInit() {
// ✅ takeUntil で自動的に購読解除
interval(1000)
.pipe(
takeUntil(this.destroy$)
)
.subscribe(value => console.log('値:', value));
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}メモリリークの検出
ts
import { interval } from 'rxjs';
import { tap } from 'rxjs';
let subscriptionCount = 0;
const trackSubscriptions = <T>() =>
tap<T>({
subscribe: () => {
subscriptionCount++;
console.log('📈 購読数:', subscriptionCount);
},
unsubscribe: () => {
subscriptionCount--;
console.log('📉 購読数:', subscriptionCount);
}
});
// 使用例
const stream$ = interval(1000).pipe(
trackSubscriptions()
);
const sub1 = stream$.subscribe();
// 出力: 📈 購読数: 1
const sub2 = stream$.subscribe();
// 出力: 📈 購読数: 2
setTimeout(() => {
sub1.unsubscribe();
// 出力: 📉 購読数: 1
}, 3000);シナリオ5: エラーが発生しているのに気づかない
- 症状: エラーが発生しているが、表示されずに無視されている
ts
import { of, throwError } from 'rxjs';
import { mergeMap, catchError } from 'rxjs';
// ❌ エラーハンドリングがないため、エラーが握りつぶされる
of(1, 2, 3)
.pipe(
mergeMap(value => {
if (value === 2) {
return throwError(() => new Error('エラー'));
}
return of(value);
})
)
.subscribe(); // エラーハンドラーなし
// ✅ 適切なエラーハンドリング
of(1, 2, 3)
.pipe(
mergeMap(value => {
if (value === 2) {
return throwError(() => new Error('エラー'));
}
return of(value);
}),
catchError(error => {
console.error('🔴 エラーをキャッチ:', error.message);
return of(-1); // フォールバック値
})
)
.subscribe({
next: value => console.log('値:', value),
error: error => console.error('🔴 購読でエラー:', error)
});
// 出力:
// 値: 1
// 🔴 エラーをキャッチ: エラー
// 値: -1グローバルエラーハンドラーの設定
ts
import { Observable } from 'rxjs';
// すべての未処理エラーをキャッチ
const originalCreate = Observable.create;
Observable.create = function(subscribe: any) {
return originalCreate.call(this, (observer: any) => {
try {
return subscribe(observer);
} catch (error) {
console.error('🔴 未処理のエラー:', error);
observer.error(error);
}
});
};シナリオ6: リトライの試行回数を追跡したい
- 症状:
retryオペレーターを使用しているが、何回リトライしているのか分からない
エラー発生時に自動的にリトライする場合、実際に何回リトライが実行されているのかを追跡することで、デバッグやログ記録が容易になります。
基本的なリトライデバッグ
ts
import { throwError, of, timer } from 'rxjs';
import { retryWhen, mergeMap, tap } from 'rxjs';
throwError(() => new Error('一時的なエラー'))
.pipe(
retryWhen((errors) =>
errors.pipe(
mergeMap((error, index) => {
const retryCount = index + 1;
console.log(`🔄 リトライ ${retryCount}回目`);
if (retryCount > 2) {
console.log('❌ 最大リトライ数に到達');
throw error;
}
return timer(1000);
})
)
)
)
.subscribe({
next: value => console.log('✅ 成功:', value),
error: error => console.log('🔴 最終エラー:', error.message)
});
// 出力:
// 🔄 リトライ 1回目
// 🔄 リトライ 2回目
// 🔄 リトライ 3回目
// ❌ 最大リトライ数に到達
// 🔴 最終エラー: 一時的なエラーTIP
リトライのデバッグ方法について、より詳細な実装パターンはretry と catchErrorの「リトライのデバッグ」セクションで解説しています。
- tap の error コールバックを使った基本的な追跡
- retryWhen での詳細なログ記録
- 指数バックオフとログ記録
- RxJS 7.4+ の retry 設定オブジェクト
まとめ
よくあるデバッグシナリオの解決方法
- ✅ 値が流れてこない → 購読忘れ、フィルタリング条件の確認
- ✅ 期待と異なる値 → オペレーターの順序、参照の共有に注意
- ✅ 購読が完了しない → 無限ストリームに
takeやtakeUntilを使用 - ✅ メモリリーク →
takeUntilパターンで自動購読解除 - ✅ エラーの見逃し → 適切なエラーハンドリングの実装
- ✅ リトライ追跡 →
retryWhenや設定オブジェクトでログ記録
関連ページ
- デバッグの基本戦略 - tap オペレーターや開発者ツールの使い方
- カスタムデバッグツール - 名前付きストリーム、デバッグオペレーター
- パフォーマンスデバッグ - 購読数監視、メモリ使用量確認
- エラーハンドリング - エラー処理の戦略