Redux-Saga を用いた PWA のオフライン同期とバックグラウンドタスク制御の実装

Redux-Saga を用いた PWA のオフライン同期とバックグラウンドタスク制御の実装

導入と課題背景

プログレッシブ Web アプリケーション(PWA)の開発において、ネットワーク環境に依存しない動作はユーザー体験の重要な要素です。しかし、通信切断時のデータ整合性の保証や、アプリ非稼働時の処理実行などは依然として技術的ハードルとなっています。Redux-Saga は、Redux アーキテクチャ上の非同期ロジックをジェネレータ関数で管理するミドルウェアであり、複雑な同期フローを宣言的に記述できます。これを PWA の機能と組み合わせることで、堅牢なオフラインファースト型のアプリケーション構築が可能になります。

Redux-Saga の主要機能概要

このセクションでは、非同期処理の流れを制御するためのサガの基本構成要素を振り返ります。

  • Saga: 非同期アクションの流れを定義するジェネレータ関数。
  • Effect: アクションディスパッチや関数呼び出しなどを記述するためのプレースホルダーオブジェクト。
  • Middleware: Redux ストアと連携し、サガを実行してエフェクトを解釈する機構。

一般的に利用されるエフェクトには以下があります:

エフェクト名 役割
take 特定のアクションの発行を待機します
put 新しいアクションをストアへ Dispatch します
call 関数を同期実行し、完了までブロックします
fork 非ブロッキングでタスクを開始します
takeLatest 特定のアクションに対して、直近のもののみを処理します

PWA におけるローカルデータの永続化戦略

PWA の核心である「オフライン動作」を実現するには、クライアントサイドでのデータ保存が必要です。IndexedDB は構造化データを大量に格納できるため、シークエンスや商品データなどの保存に適しています。

IndexedDB を活用したデータ保存

Redux-Saga の call エフェクトを使用して、データベース操作をカプセル化します。

import { call, put, select } from 'redux-saga/effects';
import { openDB } from 'idb';

// データベース接続の抽象化
function* connectToLocalStorage(databaseName, version = 1) {
  return yield call(openDB, databaseName, version, {
    upgrade(db) {
      if (!db.objectStoreNames.contains('transactions')) {
        db.createObjectStore('transactions', { keyPath: 'uuid' });
      }
    },
  });
}

// オフライン状態でのトランザクション保存
function* persistTransactionLocally(transactionData) {
  const db = yield call(connectToLocalStorage, 'app-offline-store');
  try {
    yield call([db, db.put], 'transactions', transactionData);
    console.info('Transaction stored locally:', transactionData.id);
  } catch (err) {
    throw new Error(`Database write failed: ${err.message}`);
  }
}

通信待ち行列と Channel の活用

アプリがオフライン状態で発生した更新リクエストをキューイングし、オンライン復帰時に再送する仕組みが必要です。Redux-Saga の Channel は、このバッファリング用途に最適です。

import { take, call, fork, put } from 'redux-saga/effects';
import { channel, buffers } from 'redux-saga';

function* setupOfflineRequestHandler() {
  // リクエストを保持するバッファ付きチャンネルを作成
  const requestBuffer = buffers.sliding(100); 
  const queueChannel = yield call(channel, buffers.buffer(requestBuffer));

  // 同期ワーカーを起動
  yield fork(handlePendingRequestsQueue, queueChannel);

  while (true) {
    // メインスレッドからのアクションを監視
    const { type, payload, meta } = yield take(action => action.meta?.needsPersistence);
    
    if (!meta.needsPersistence) continue;

    // チャンネルへ作業を投入
    yield put(queueChannel, { type, payload, timestamp: Date.now() });
  }
}

function* handlePendingRequestsQueue(ch) {
  while (true) {
    const { type, payload, timestamp } = yield take(ch);
    const isAvailable = yield call(checkConnectivityStatus);

    if (isAvailable) {
      yield call(executeRemoteApiCall, type, payload);
    } else {
      // オンラインでない場合は永続化層に退避
      yield call(persistTransactionLocally, payload);
    }
  }
}

バックグラウンド同期機能の実装

サービスワーカーを使用することで、タブを閉じた後でもデータ同期が可能です。これは Background Sync API によって実現され、Redux-Saga 側ではメッセージ通信を通じて通知を受信します。

Background Sync API との連携

イベントチャンネルを作成し、サービスワーカーからのメッセージを Redux Store に渡すパターンです。

import { eventChannel } from 'redux-saga';

function createSyncEventListener(emitter) {
  const handleSyncMessage = (evt) => {
    if (evt.data && evt.data.status === 'SYNC_FINISHED') {
      emitter({ type: 'ONLINE_SYNC_COMPLETE', result: evt.data.response });
      emitter.end(); // 処理完了後にチャンネル終了
    }
  };

  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.ready.then((reg) => {
      // タグ付き同期を登録
      reg.sync.register('pending-updates').catch(console.error);
    });

    navigator.serviceWorker.addEventListener('message', handleSyncMessage);
  }

  // クリーンアップ関数
  return () => {
    navigator.serviceWorker.removeEventListener('message', handleSyncMessage);
  };
}

function* monitorServiceWorkerSync() {
  const syncChan = yield call(eventChannel, createSyncEventListener);
  
  try {
    while (true) {
      const msg = yield take(syncChan);
      yield put({ type: 'REFRESH_DATA_STATE', data: msg.result });
    }
  } finally {
    // フォーカスが外れた場合などの処理
    yield cancel(syncChan);
  }
}

定期タスクのスケジューリング

周期性のあるデータ取得も、delay エフェクトを用いてシンプルに記述できます。

import { take, cancel, fork, delay, call } from 'redux-saga/effects';

function* periodicUpdateLoop(intervalMs) {
  while (true) {
    yield delay(intervalMs);
    // バックグラウンドでのデータフェッチ
    const latest = yield call(fetchLatestUpdatesFromAPI);
    yield put({ type: 'UPDATE_GLOBAL_CACHE', items: latest });
  }
}

function* managePeriodicTasks() {
  let taskHandle = null;

  while (true) {
    const { type } = yield take(['START_PERIODIC_UPDATE', 'STOP_PERIODIC_UPDATE']);
    
    if (type === 'START_PERIODIC_UPDATE') {
      if (!taskHandle) {
        taskHandle = yield fork(periodicUpdateLoop, 5 * 60 * 1000); // 5 分間隔
      }
    } else {
      if (taskHandle) {
        yield cancel(taskHandle);
        taskHandle = null;
      }
    }
  }
}

エラー回復とリトライメカニズム

ネットワーク不安定性に対応するため、指数バックオフを含むリトライロジックを実装します。

async function delayedRetry(fn, maxAttempts) {
  let attempts = 0;
  while (attempts < maxAttempts) {
    try {
      await fn();
      return true;
    } catch (err) {
      attempts++;
      if (attempts >= maxAttempts) throw err;
      const waitTime = Math.pow(2, attempts) * 1000;
      await new Promise(resolve => setTimeout(resolve, waitTime));
    }
  }
}

function* resilientSyncOperation() {
  try {
    const success = yield call(delayedRetry, fetchServerState, 3);
    if (success) {
      yield put({ type: 'SYNC_SUCCESS' });
    }
  } catch (error) {
    yield put({ type: 'SYNC_FAILED', error: error.message });
    // ロック状態を解除するなどのフォールバック
  }
}

統合事例:オフライン対応ショッピングカート

以上の概念を応用し、カートアイテム管理の実装例を示します。主な変更点は、状態のローカル保存とネットワーク検知後の非同期同期を統合している点です。

// src/sagas/cartOperations.js
import { takeEvery, call, put, fork, select } from 'redux-saga/effects';
import { addToCartAPI, clearCartAPI } from '../api/productService';
import { CACHE_KEY } from '../constants/storage';

// ローカルストレージへの書き込み支援
function saveCartSnapshot(cartItems) {
  return new Promise((resolve) => {
    localStorage.setItem(CACHE_KEY.CART, JSON.stringify(cartItems));
    resolve();
  });
}

// カート更新サガ
function* updateCartFlow(action) {
  const currentItems = yield select(state => state.cart.items);
  
  // UI の即時反映
  yield put({ type: 'CART_UI_UPDATED', item: action.payload });
  
  // 永続化
  yield call(saveCartSnapshot, [...currentItems, action.payload]);
  
  // 通信状況の確認
  const isReachable = yield call(navigator.onLine);
  
  if (isReachable) {
    yield call(addToCartAPI, action.payload);
  } else {
    // 待機列に登録済みであればスキップ、なければ明示的なマーク
    yield put({ type: 'MARKED_FOR_LATER_SYNC', actionPayload: action.payload });
  }
}

export default function* rootWatcher() {
  yield takeEvery('ADD_ITEM_ACTION', updateCartFlow);
  // 他の同期関連サガをここに Fork
}

技術的要点のまとめ

本稿では、Redux-Saga の生成器機能を活用し、PWA のオフライン能力を拡張する手法を解説しました。主な技術的ポイントは以下の通りです。

  • 状態管理: Channel を使用して、非同期の外部イベントを内部的なアクションフローに安全に変換。
  • データ耐久性: IndexedDB や LocalStorage と連携し、セッション喪失後もデータを維持。
  • 信頼性の向上: サービスワーカーとの双方向通信により、アプリの裏側でもデータを整合保つ。
  • 柔軟性: 標準のエフェクト(takeLatest, cancel)を組み合わせて、競合条件を防止。

将来の Web プラットフォームの進化に伴い、これらのパターンはさらに洗練されていくでしょう。特に Service Worker の機能拡張や Web Workers との連携により、より高負荷なデータ処理もフロントエンドで安定して処理可能になることが期待されます。

タグ: Redux-Saga progressive-web-apps service-workers IndexedDB asynchronous-javascript

7月3日 17:07 投稿