メインコンテンツへスキップ

ノンブロッキング呼び出し

前のセクションでは、`take`エフェクトが、中心的な場所で、自明でないフローをより良く記述する方法を見てきました。

ログインフローの例を再検討する

function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}

例を完成させて、実際のログイン/ログアウトロジックを実装しましょう。リモートサーバーでユーザーを認証できるAPIがあると仮定します。認証が成功した場合、サーバーは認証トークンを返します。このトークンは、DOMストレージを使用してアプリケーションによって保存されます(APIがDOMストレージ用の別のサービスを提供すると仮定します)。

ユーザーがログアウトすると、以前に保存した認証トークンを削除します。

最初の試み

これまでのところ、上記のフローを実装するために必要なすべてのエフェクトが揃っています。`take`エフェクトを使用して、ストア内の特定のアクションを待機できます。`call`エフェクトを使用して、非同期呼び出しを行うことができます。最後に、`put`エフェクトを使用して、アクションをストアにディスパッチできます。

試してみましょう

注:以下のコードには、微妙な問題があります。必ず最後までセクションを読んでください。

import { take, call, put } from 'redux-saga/effects'
import Api from '...'

function* authorize(user, password) {
try {
const token = yield call(Api.authorize, user, password)
yield put({type: 'LOGIN_SUCCESS', token})
return token
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
}
}

function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
const token = yield call(authorize, user, password)
if (token) {
yield call(Api.storeItem, {token})
yield take('LOGOUT')
yield call(Api.clearItem, 'token')
}
}
}

最初に、実際のAPI呼び出しを実行し、成功時にストアに通知する別のジェネレーター`authorize`を作成しました。

`loginFlow`は、`while (true)`ループ内でフロー全体を実装しています。つまり、フローの最後のステップ(`LOGOUT`)に到達すると、新しい`LOGIN_REQUEST`アクションを待機して新しいイテレーションを開始します。

`loginFlow`は、最初に`LOGIN_REQUEST`アクションを待機します。次に、アクションペイロード(`user`と`password`)で資格情報を取得し、`authorize`タスクを`call`します。

ご存知のように、`call`はPromiseを返す関数を呼び出すためだけのものではありません。他のジェネレーター関数を呼び出すためにも使用できます。上記の例では、**`loginFlow`は、`authorize`が終了して戻るまで待機します**(つまり、API呼び出しを実行し、アクションをディスパッチしてから、`loginFlow`にトークンを返すまで)。

API呼び出しが成功した場合、`authorize`は`LOGIN_SUCCESS`アクションをディスパッチし、フェッチされたトークンを返します。エラーが発生した場合、`LOGIN_ERROR`アクションをディスパッチします。

`authorize`の呼び出しが成功した場合、`loginFlow`は返されたトークンをDOMストレージに保存し、`LOGOUT`アクションを待機します。ユーザーがログアウトすると、保存されたトークンを削除し、新しいユーザーログインを待機します。

`authorize`が失敗した場合、`undefined`を返し、`loginFlow`は前のプロセスをスキップして、新しい`LOGIN_REQUEST`アクションを待機します。

全体のロジックが1つの場所に保存されていることに注目してください。新しい開発者がコードを読んでも、コントロールフローを理解するためにさまざまな場所を行き来する必要はありません。同期アルゴリズムを読んでいるようなものです。ステップは自然な順序でレイアウトされています。また、他の関数を呼び出してその結果を待つ関数があります。

しかし、上記の方法にはまだ微妙な問題があります

`loginFlow`が次の呼び出しが解決するのを待っていると仮定します

function* loginFlow() {
while (true) {
// ...
try {
const token = yield call(authorize, user, password)
// ...
}
// ...
}
}

ユーザーが`ログアウト`ボタンをクリックすると、`LOGOUT`アクションがディスパッチされます。

次の例は、イベントの仮説的なシーケンスを示しています

UI                              loginFlow
--------------------------------------------------------
LOGIN_REQUEST...................call authorize.......... waiting to resolve
........................................................
........................................................
LOGOUT.................................................. missed!
........................................................
................................authorize returned...... dispatch a `LOGIN_SUCCESS`!!
........................................................

`loginFlow`が`authorize`の呼び出しでブロックされている場合、呼び出しと応答の間で発生する可能性のある`LOGOUT`は、`loginFlow`がまだ`yield take('LOGOUT')`を実行していないため、見逃されます。

上記コードの問題は、`call`がブロッキングエフェクトであるということです。つまり、ジェネレーターは、呼び出しが終了するまで、他の何も実行/処理できません。しかし、私たちの場合、`loginFlow`は認証呼び出しを実行するだけでなく、この呼び出しの途中で発生する可能性のある`LOGOUT`アクションを監視することも望んでいます。これは、`LOGOUT`が`authorize`呼び出しと並行であるためです。

したがって、必要なのは、`loginFlow`が継続して発生する可能性のある/並行する`LOGOUT`アクションを監視できるように、ブロッキングせずに`authorize`を開始する方法です。

ノンブロッキング呼び出しを表現するために、ライブラリは別のエフェクトを提供します:`fork`。*タスク*をフォークすると、タスクはバックグラウンドで開始され、呼び出し元はフォークされたタスクが終了するのを待たずにフローを継続できます。

したがって、`loginFlow`が並行する`LOGOUT`を見逃さないようにするには、`authorize`タスクを`call`するのではなく、`fork`する必要があります。

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

function* loginFlow() {
while (true) {
...
try {
// non-blocking call, what's the returned value here ?
const ?? = yield fork(authorize, user, password)
...
}
...
}
}

ここでの問題は、`authorize`アクションがバックグラウンドで開始されるため、(待つ必要があるため)`token`結果を取得できないということです。したがって、トークンストレージ操作を`authorize`タスクに移動する必要があります。

import { fork, call, take, put } from 'redux-saga/effects'
import Api from '...'

function* authorize(user, password) {
try {
const token = yield call(Api.authorize, user, password)
yield put({type: 'LOGIN_SUCCESS', token})
yield call(Api.storeItem, {token})
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
}
}

function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
yield fork(authorize, user, password)
yield take(['LOGOUT', 'LOGIN_ERROR'])
yield call(Api.clearItem, 'token')
}
}

`yield take(['LOGOUT', 'LOGIN_ERROR'])`も実行しています。つまり、2つの並行するアクションを監視しています。

  • ユーザーがログアウトする前に`authorize`タスクが成功した場合、`LOGIN_SUCCESS`アクションをディスパッチして終了します。その後、`loginFlow` sagaは、将来の`LOGOUT`アクションのみを待機します(`LOGIN_ERROR`は発生しないため)。

  • ユーザーがログアウトする前に`authorize`が失敗した場合、`LOGIN_ERROR`アクションをディスパッチして終了します。したがって、`loginFlow`は`LOGOUT`の前に`LOGIN_ERROR`を取得し、別の`while`イテレーションに入り、次の`LOGIN_REQUEST`アクションを待機します。

  • ユーザーが`authorize`が終了する前にログアウトした場合、`loginFlow`は`LOGOUT`アクションを取得し、次の`LOGIN_REQUEST`も待機します。

`Api.clearItem`の呼び出しはべき等であると想定されていることに注意してください。`authorize`呼び出しによってトークンが保存されていない場合、効果はありません。`loginFlow`は、次のログインを待機する前に、ストレージにトークンがないことを確認します。

しかし、まだ完了していません。API呼び出しの途中で`LOGOUT`を取得した場合、`authorize`プロセスを**キャンセル**する必要があります。そうしないと、2つの並行タスクが並行して進化します。`authorize`タスクは実行を継続し、成功した(または失敗した)結果が出ると、`LOGIN_SUCCESS`(または`LOGIN_ERROR`)アクションをディスパッチし、不整合な状態になります。

フォークされたタスクをキャンセルするには、専用のエフェクト`cancel`を使用します

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

// ...

function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
// fork return a Task object
const task = yield fork(authorize, user, password)
const action = yield take(['LOGOUT', 'LOGIN_ERROR'])
if (action.type === 'LOGOUT')
yield cancel(task)
yield call(Api.clearItem, 'token')
}
}

`yield fork`の結果は、タスクオブジェクトになります。返されたオブジェクトをローカル定数`task`に割り当てます。後で`LOGOUT`アクションを取得した場合、そのタスクを`cancel`エフェクトに渡します。タスクがまだ実行中の場合、中止されます。タスクが既に完了している場合は、何も起こらず、キャンセルは何も操作しないことになります。そして最後に、タスクがエラーで完了した場合、タスクは既に完了していることがわかっているため、何も行いません。

私たちは*ほぼ*完了しました(並行処理はそれほど簡単ではありません。真剣に取り組む必要があります)。

`LOGIN_REQUEST`アクションを受信すると、リデューサーが`isLoginPending`フラグをtrueに設定し、UIにメッセージまたはスピナーを表示できるようにすると仮定します。API呼び出しの途中で`LOGOUT`を取得し、*強制終了*してタスクを中止した場合(つまり、タスクがすぐに停止した場合)、再び不整合な状態になる可能性があります。`isLoginPending`はまだtrueに設定されており、リデューサーは結果アクション(`LOGIN_SUCCESS`または`LOGIN_ERROR`)を待機しています。

幸いなことに、`cancel`エフェクトは`authorize`タスクを強引に強制終了しません。代わりに、クリーンアップロジックを実行する機会を与えます。キャンセルされたタスクは、`finally`ブロックで、キャンセルロジック(およびその他のタイプの完了)を処理できます。finallyブロックは、あらゆるタイプの完了(通常の戻り、エラー、または強制キャンセル)で実行されるため、特別な方法でキャンセルを処理したい場合に使用できる`cancelled`エフェクトがあります。

import { take, call, put, cancelled } from 'redux-saga/effects'
import Api from '...'

function* authorize(user, password) {
try {
const token = yield call(Api.authorize, user, password)
yield put({type: 'LOGIN_SUCCESS', token})
yield call(Api.storeItem, {token})
return token
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
} finally {
if (yield cancelled()) {
// ... put special cancellation handling code here
}
}
}

`isLoginPending`状態のクリアについては何も行っていないことに気付いたかもしれません。そのためには、少なくとも2つの可能な解決策があります。

  • 専用アクション`RESET_LOGIN_PENDING`をディスパッチする
  • `LOGOUT`アクションでリデューサーが`isLoginPending`をクリアするようにする