跳至主要内容

非阻塞调用

在上一节中,我们看到了 take Effect 如何让我们更好地在一个中心位置描述一个非平凡的流程。

重新审视登录流程示例

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

让我们完成示例并实现实际的登录/注销逻辑。假设我们有一个 API,它允许我们在远程服务器上授权用户。如果授权成功,服务器将返回一个授权令牌,我们的应用程序将使用 DOM 存储来存储该令牌(假设我们的 API 提供了另一种用于 DOM 存储的服务)。

当用户注销时,我们将删除之前存储的授权令牌。

第一次尝试

到目前为止,我们已经拥有实现上述流程所需的所有 Effect。我们可以使用 take Effect 等待存储中的特定操作。我们可以使用 call Effect 进行异步调用。最后,我们可以使用 put Effect 向存储中分发操作。

让我们试一试

注意:以下代码存在一个细微问题。请确保阅读本节内容直至结束。

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

首先,我们创建了一个单独的 Generator authorize,它将执行实际的 API 调用并在成功时通知 Store。

loginFlowwhile (true) 循环中实现其整个流程,这意味着一旦我们到达流程的最后一步(LOGOUT),我们将通过等待新的 LOGIN_REQUEST 操作来开始新的迭代。

loginFlow 首先等待 LOGIN_REQUEST 操作。然后,它检索操作有效负载中的凭据(userpassword),并对 authorize 任务进行 call

正如您所注意到的,call 不仅用于调用返回 Promise 的函数。我们也可以使用它来调用其他 Generator 函数。在上面的示例中,loginFlow 将等待 authorize 直到它终止并返回(即在执行 API 调用、分发操作然后将令牌返回给 loginFlow 之后)。

如果 API 调用成功,authorize 将分发 LOGIN_SUCCESS 操作,然后返回获取的令牌。如果它导致错误,它将分发 LOGIN_ERROR 操作。

如果调用 `authorize` 成功,`loginFlow` 会将返回的 token 存储在 DOM 存储中,并等待 `LOGOUT` 操作。当用户注销时,我们会删除存储的 token 并等待新的用户登录。

如果 `authorize` 失败,它将返回 `undefined`,这将导致 `loginFlow` 跳过之前的流程并等待新的 `LOGIN_REQUEST` 操作。

请注意整个逻辑是如何存储在一个地方的。新开发者阅读我们的代码时,无需在不同的地方之间跳转以了解控制流程。这就像阅读同步算法:步骤按其自然顺序排列。并且我们有调用其他函数并等待其结果的函数。

但是,上述方法仍然存在一个细微的问题

假设当 `loginFlow` 正在等待以下调用的解析时

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

用户点击 `Logout` 按钮,导致 `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` 调用是并发的。

因此,需要某种方法来启动 `authorize` 而不阻塞,以便 `loginFlow` 可以继续并观察最终/并发 `LOGOUT` 操作。

为了表达非阻塞调用,库提供了另一个效果:fork。当我们 fork 一个任务时,该任务将在后台启动,调用者可以继续其流程,而无需等待 fork 的任务终止。

因此,为了使 `loginFlow` 不错过并发 `LOGOUT`,我们不能 `call` `authorize` 任务,而是必须 `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` 结果(因为我们必须等待它)。因此,我们需要将 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'])`。这意味着我们正在监听两个并发动作。

  • 如果 `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` 进程,否则我们将有两个并发任务并行执行:`authorize` 任务将继续运行,并在成功(或失败)后分发 `LOGIN_SUCCESS`(或 `LOGIN_ERROR`)动作,导致状态不一致。

为了取消一个分叉的任务,我们使用一个专门的 Effect 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 对象。我们将返回的对象分配给一个局部常量 `task`。稍后,如果我们获取一个 `LOGOUT` 动作,我们将把该任务传递给 `cancel` Effect。如果任务仍在运行,它将被中止。如果任务已经完成,则不会发生任何事情,取消将导致无操作。最后,如果任务完成时出现错误,我们将不做任何事情,因为我们知道任务已经完成。

我们几乎完成了(并发并不容易;你必须认真对待它)。

假设当我们收到一个 LOGIN_REQUEST 动作时,我们的 reducer 会将 isLoginPending 标志设置为 true,以便它可以在 UI 中显示一些消息或加载动画。如果我们在 API 调用过程中收到 LOGOUT 并通过终止(即立即停止任务)来中止任务,那么我们可能会再次陷入不一致的状态。我们仍然会将 isLoginPending 设置为 true,并且我们的 reducer 会等待结果动作(LOGIN_SUCCESSLOGIN_ERROR)。

幸运的是,cancel Effect 不会粗暴地终止我们的 authorize 任务。相反,它会给它一个机会执行它的清理逻辑。被取消的任务可以在它的 finally 块中处理任何取消逻辑(以及任何其他类型的完成)。由于 finally 块在任何类型的完成(正常返回、错误或强制取消)时都会执行,因此有一个 Effect 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
  • 让 reducer 在 LOGOUT 动作上清除 isLoginPending