非阻塞调用
在上一节中,我们看到了如何使用 take
Effect 更好地在一个中心位置描述一个非平凡的流程。
回顾一下登录流程的例子:
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... 执行登录逻辑
yield take('LOGOUT')
// ... 执行登出逻辑
}
}
让我们完成这个例子并实现实际的登录/登出逻辑。假设我们有一个 API,它允许我们在远程服务器上授权用户。如果授权成功,服务器将返回一个授权令牌,我们的应用程序将使用 DOM 存储来存储它(假设我们的 API 提供了另一个用于 DOM 存储的服务)。
当用户登出时,我们将删除之前存储的授权令牌。
第一次尝试
到目前为止,我们已经有了所有需要实现上述流程的 Effects。我们可以使用 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。
loginFlow
在 while (true)
循环内实现其整个流程,这意味着一旦我们到达流程的最后一步(LOGOUT
),我们就会开始新的迭代,等待新的 LOGIN_REQUEST
动作。
loginFlow
首先等待一个 LOGIN_REQUEST
动作。然后,它在动作负载中检索凭据(user
和 password
),并对 authorize
任务进行 call
。
如你所见,call
不仅用于调用返回 Promise 的函数。我们也可以用它来调用其他 Generator 函数。在上面的例子中,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
按钮,导致派发了一个 LOGOUT
动作。
以下示例说明了事件的假设顺序:
UI loginFlow
--------------------------------------------------------
LOGIN_REQUEST...................call authorize.......... 等待解决
........................................................
........................................................
LOGOUT.................................................. 错过了!
........................................................
................................authorize 返回了........ 派发一个 `LOGIN_SUCCESS`!!
........................................................
当 loginFlow
在 authorize
调用上被阻塞时,调用和响应之间发生的任何 LOGOUT
都会被错过,因为 loginFlow
还没有执行 yield take('LOGOUT')
。
上述代码的问题在于 call
是一个阻塞效应。也就是说,生成器在调用结束之前不能执行/处理其他任何事情。但在我们的情况下,我们不仅希望 loginFlow
执行授权调用,还希望在此调用中间观察可能发生的 LOGOUT
动作。这是因为 LOGOUT
是与 authorize
调用 并发 的。
所以,我们需要的是一种启动 authorize
而不阻塞 loginFlow
的方法,这样 loginFlow
就可以继续并观察可能的/并发的 LOGOUT
动作。
为了表达非阻塞调用,库提供了另一个效应:fork
。当我们 fork 一个 任务 时,任务在后台启动,调用者可以在不等待 fork 的任务结束的情况下继续其流程。
所以,为了让 loginFlow
不错过并发的 LOGOUT
,我们不能 call
authorize
任务,而必须 fork
它。
import { fork, call, take, put } from 'redux-saga/effects'
function* loginFlow() {
while (true) {
...
try {
// 非阻塞调用,这里返回的值是什么?
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
)动作,导致状态不一致。
为了取消一个 forked 任务,我们使用一个专用的 Effect cancel
import { take, put, call, fork, cancel } from 'redux-saga/effects'
// ...
function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
// fork 返回一个 Task 对象
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