跳到主要內容

非阻塞呼叫

在上一節中,我們看到 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')
}
}
}

首先,我們建立一個獨立的產生器 `authorize`,執行實際的 API 呼叫,並在成功時通知 Store 。

`loginFlow` 在 `while (true)` 迴圈中實作了完整的流程,這表示一旦我們到達最後一個流程步驟 ( `LOGOUT` ) 時,我們會等待新的 `LOGIN_REQUEST` 動作開始新的反覆運作。

`loginFlow` 會先等待一個 `LOGIN_REQUEST` 動作。接著,它會從動作負載 ( `user` 和 `password` ) 中擷取身分證明,並呼叫 `authorize` 工作至 `call`。

如你所見, `call` 不只用來呼叫回傳承諾的函數。我們也可以用它來呼叫其他產生器函數。在上面的範例中,** `loginFlow` 會等待 `authorize` 直到它終止並回傳** (即執行 API 呼叫、派發動作,然後將代幣回傳至 `loginFlow` 之後)。

如果 API 呼叫成功, `authorize` 會派發 `LOGIN_SUCCESS` 動作,然後回傳取得的代幣。如果產生錯誤,它會派發 `LOGIN_ERROR` 動作。

如果呼叫 `authorize` 成功, `loginFlow` 會將回傳的代幣儲存至 DOM 儲存資料空間,並等待 `LOGOUT` 動作。當使用者登出時,我們會移除儲存的代幣,並等待新使用者登入。

如果 `authorize` 失敗,它會回傳 `undefined`,這將導致 `loginFlow` 略過先前的處理程序繼續執行,並等待新的 `LOGIN_REQUEST` 動作。

請注意,完整的邏輯都儲存在同一個地方。閱讀我們程式碼的新開發人員不需在不同的地方搜尋以理解控制流程。這就像閱讀同步演算法:步驟以其順序呈現。而我們的函數會呼叫其他函數並等待結果。

但上述方法還有一個不易察覺的問題

假設當 `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` 呼叫同時進行。

因此,需要的時啟動 授權 而不會發生阻擋,這樣 登入流程 就可繼續,並監控最終/同時發生的 登出 動作。

為了表示非阻擋呼叫,此函式庫提供了另一個 Effect:分岔。當我們分岔出一個 任務 時,這個任務會在背景中啟動,而呼叫者則可以持續執行流程,而不用等候已分岔的任務結束。

因此為了讓 登入流程 不會錯過同時發生的 登出,我們不能 呼叫 授權 任務,而必須 分岔 它。

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)
...
}
...
}
}

現在的問題是,由於我們的 授權 動作會在背景中啟動,因此我們無法取得 令牌 結果(因為我們必須等候它)。因此,我們需要將令牌儲存操作移至 授權 任務中。

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(['登出', '登入錯誤'])。表示我們正在監控 2 個同時發生的動作

  • 如果在使用者登出前 授權 任務成功,它會發佈 登入成功 動作,然後終止。我們的 登入流程 程式碼區塊會在接下來只等候 登出 動作(因為 登入錯誤 永遠不會發生)。

  • 如果在使用者登出前 授權 失敗,它會發佈 登入錯誤 動作,然後終止。因此 登入流程 會在 登出 前先接收 登入錯誤,然後會進入另一個 while 迭代,並等候下一個 登入要求 動作。

  • 如果使用者在 授權 終止前登出,則 登入流程 會接收 登出 動作,也會等候下一個 登入要求

請注意,對 Api.clearItem 的呼叫應該是冪等的。如果未由 授權 呼叫儲存任何令牌,則不會有任何作用。登入流程會確保在等候下一個登入前儲存區中沒有任何令牌。

但是,我們尚未完成。如果在 API 呼叫中段我們執行 登出,則我們必須 取消 授權 程序,否則我們會有 2 個同時發生的任務平行發展:授權 任務會繼續執行,並且在成功(resp。失敗)結果時,會發佈一個 登入成功(resp。登入錯誤)動作,導致狀態不一致。

為了取消分岔任務,我們使用專用的 Effect 取消

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 的動作時,我們的 reducer 會將某個 isLoginPending 旗標設為 true,以便它能在 UI 中顯示一些訊息或旋轉圖示。如果我們在 API 呼叫時收到 LOGOUT 並透過殺死任務來中止它(即立即停止任務),我們可能會再次導致不一致的狀態。我們仍然會有設定為 true 的 isLoginPending,而且我們的 reducer 會等待結果動作(LOGIN_SUCCESSLOGIN_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 狀態。對於那個,至少有兩種可能的解決方案

  • 發送專屬動作 RESET_LOGIN_PENDING
  • 針對 LOGOUT 動作,讓 reducer 去清除 isLoginPending