无阻塞调用

在上一节中,我们看到了 take Effect 让我们可以在一个集中的地方更好地去描述一个非常规的流程。

重温一下登录流程示例:

  1. function* loginFlow() {
  2. while(true) {
  3. yield take('LOGIN')
  4. // ... perform the login logic
  5. yield take('LOGOUT')
  6. // ... perform the logout logic
  7. }
  8. }

让我们来完成这个例子,并实现真实的登录/登出逻辑。假设有这样一个 Api,它允许我们在一个远程服务器上验证用户的权限。
如果验证成功,服务器将会返回一个授权令牌,我们的应用程序将会通过 DOM storage 存储这个令牌(假设我们的 Api 为 DOM storage 提供了另外一个服务)。

当用户登出,我们将直接删除以前存储的授权令牌。

初次尝试

到目前为止,我们拥有所有需要的 Effects 用来实现上述流程。我们可以使用 take Effect 等待 store 中指定的 action。
我们也可以使用 call Effect 进行同步调用,最后使用 put Effect 来发起 action 到 store。

让我们试试吧:

注意,以下代码有一个小问题,请务必将这一节全部阅读完。

  1. import { take, call, put } from 'redux-saga/effects'
  2. import Api from '...'
  3. function* authorize(user, password) {
  4. try {
  5. const token = yield call(Api.authorize, user, password)
  6. yield put({type: 'LOGIN_SUCCESS', token})
  7. return token
  8. } catch(error) {
  9. yield put({type: 'LOGIN_ERROR', error})
  10. }
  11. }
  12. function* loginFlow() {
  13. while(true) {
  14. const {user, password} = yield take('LOGIN_REQUEST')
  15. const token = yield call(authorize, user, password)
  16. if(token) {
  17. yield call(Api.storeItem({token}))
  18. yield take('LOGOUT')
  19. yield call(Api.clearItem('token'))
  20. }
  21. }
  22. }

首先我们创建了一个独立的 Generator authorize,它将执行真实的 Api 调用并在成功后通知 Store。

loginFlow 在一个 while(true) 循环中实现它所有的流程,这样做的意思是:一旦到达流程最后一步(LOGOUT),通过等待一个新的 LOGIN_REQUEST action 来启动一个新的迭代。

loginFlow 首先等待一个 LOGIN_REQUEST action。
然后在 action 的 payload 中获取有效凭据(即 userpassword)并调用一个 callauthorize 任务。

正如你注意到的,call 不仅可以用来调用返回 Promise 的函数。我们也可以用它来调用其他 Generator 函数。
在上面的例子中,loginFlow 将等待 authorize 直到它终止或返回(即执行 api 调用后,发起 action 然后返回 token 至 loginFlow)。

如果 Api 调用成功了,authorize 将发起一个 LOGIN_SUCCESS action 然后返回获取到的 token。
如果调用导致了错误,将会发起一个 LOGIN_ERROR action。

如果调用 authorize 成功,loginFlow 将在 DOM storage 中存储返回的 token,并等待 LOGOUT action。
当用户登出,我们删除存储的 token 并等待一个新的用户登录。

authorize 失败的情况下,它将返回一个 undefined 值,这将导致 loginFlow 跳过当前处理进程并等待一个新的 LOGIN_REQUEST action。

观察整个逻辑是如何存储在一个地方的。一个新的开发者阅读我们的代码时,不必再为了理解控制流而在各个地方来回切换。
这就像是在阅读同步代码:它们的自然顺序确定了执行步骤。并且我们有很多 Effects 可以调用其他函数并等待它们的结果。

但上面的方法还是有一个小问题

假设 loginFlow 正在等待如下的调用被 resolve:

  1. function* loginFlow() {
  2. while(true) {
  3. ...
  4. try {
  5. const token = yield call(authorize, user, password)
  6. ...
  7. }
  8. ...
  9. }
  10. }

但用户点击了 Logout 按钮使得 LOGOUT action 被发起。

下面的例子演示了假想的一系列事件:

  1. UI loginFlow
  2. --------------------------------------------------------
  3. LOGIN_REQUEST...................call authorize.......... waiting to resolve
  4. ........................................................
  5. ........................................................
  6. LOGOUT.................................................. missed!
  7. ........................................................
  8. ................................authorize returned...... dispatch a `LOGIN_SUCCESS`!!
  9. ........................................................

loginFlowauthorize 中被阻塞了,最终发生在开始调用和收到响应之间的 LOGOUT 将会被错过,
因为那时 loginFlow 还没有执行 yield take('LOGOUT')

上面代码的问题是 call 是一个会阻塞的 Effect。即 Generator 在调用结束之前不能执行或处理任何其他事情。
但在我们的情况中,我们不仅希望 loginFlow 执行授权调用,也想监听可能发生在调用未完成之前的 LOGOUT action。
因为 LOGOUT 与调用 authorize并发的

所以我们需要的是一些非阻塞调用 authorize 的方法。这样 loginFlow 就可以继续执行,并且监听并发的或响应未完成之前发出的 LOGOUT action。

为了表示无阻塞调用,redux-saga 提供了另一个 Effect:fork
当我们 fork 一个 任务,任务会在后台启动,调用者也可以继续它自己的流程,而不用等待被 fork 的任务结束。

所以为了让 loginFlow 不错过一个并发的 LOGOUT,我们不应该使用 call 调用 authorize 任务,而应该使用 fork

  1. import { fork, call, take, put } from 'redux-saga/effects'
  2. function* loginFlow() {
  3. while(true) {
  4. ...
  5. try {
  6. // 无阻塞调用,这里返回的值是什么?
  7. const ?? = yield fork(authorize, user, password)
  8. ...
  9. }
  10. ...
  11. }
  12. }

现在的问题是,自从 authorize 的 action 在后台启动之后,我们获取不到 token 的结果(因为我们不应该等待它)。
所以我们需要将 token 存储操作移到 authorize 任务内部。

  1. import { fork, call, take, put } from 'redux-saga/effects'
  2. import Api from '...'
  3. function* authorize(user, password) {
  4. try {
  5. const token = yield call(Api.authorize, user, password)
  6. yield put({type: 'LOGIN_SUCCESS', token})
  7. } catch(error) {
  8. yield put({type: 'LOGIN_ERROR', error})
  9. }
  10. }
  11. function* loginFlow() {
  12. while(true) {
  13. const {user, password} = yield take('LOGIN_REQUEST')
  14. yield fork(authorize, user, password)
  15. yield take(['LOGOUT', 'LOGIN_ERROR'])
  16. yield call(Api.clearItem('token'))
  17. }
  18. }

我们使用了 yield take(['LOGOUT', 'LOGIN_ERROR'])。意思是监听 2 个并发的 action:

  • 如果 authorize 任务在用户登出之前成功了,它将会发起一个 LOGIN_SUCCESS action 然后结束。
    然后 loginFlow Saga 只会等待一个未来的 LOGOUT action 被发起(因为 LOGIN_ERROR 永远不会发生)。

  • 如果 authorize 在用户登出之前失败了,它将会发起一个 LOGIN_ERROR action 然后结束。
    那么 loginFlow 将在 LOGOUT 之前收到 LOGIN_ERROR,然后它会进入另外一个 while 迭代并等待下一个 LOGIN_REQUEST action。

  • 如果在 authorize 结束之前,用户就登出了,那么 loginFlow 将收到一个 LOGOUT action 并且也会等待下一个 LOGIN_REQUEST

注意 Api.clearItem 应该是幂等调用。如果 authorize 调用时没有存储 token 也不会有任何影响。
loginFlow 仅仅是保证在等待下一次登录之前,storage 中没有 token。

但是还没完。如果我们在 Api 调用期间收到一个 LOGOUT action,我们必须要 取消 authorize 处理进程,否则将有 2 个并发的任务,
并且 authorize 任务将会继续运行,并在成功的响应(或失败的响应)返回后发起一个 LOGIN_SUCCESS action(或一个 LOGIN_ERROR action),而这将导致状态不一致。

为了取消 fork 任务,我们可以使用一个指定的 Effect cancel

  1. import { take, put, call, fork, cancel } from 'redux-saga/effects'
  2. // ...
  3. function* loginFlow() {
  4. while(true) {
  5. const {user, password} = yield take('LOGIN_REQUEST')
  6. // fork return a Task object
  7. const task = yield fork(authorize, user, password)
  8. const action = yield take(['LOGOUT', 'LOGIN_ERROR'])
  9. if(action.type === 'LOGOUT')
  10. yield cancel(task)
  11. yield call(Api.clearItem('token'))
  12. }
  13. }

yield fork 的返回结果是一个 Task 对象
我们将它们返回的对象赋给一个本地常量 task。如果我们收到一个 LOGOUT action,我们将那个 task 传入给 cancel Effect。
如果任务仍在运行,它会被中止。如果任务已完成,那什么也不会发生,取消操作将会是一个空操作(no-op)。最后,如果该任务完成了但是有错误,
那我们什么也没做,因为我们知道,任务已经完成了。

OK,我们 几乎 要完成了(是的,它的并发性并不容易,你必须认真对待)。

假设在我们收到一个 LOGIN_REQUEST action 时,我们在 reducer 中设置了一些 isLoginPending 标识为 true,以便可以在界面上显示一些消息或者旋转 loading。
如果此时我们在 Api 调用期间收到一个 LOGOUT action,并通过 杀死它(即任务被立即停止)简单粗暴地中止任务。
那我们可能又以不一致的状态结束了。因为 isLoginPending 仍然是 true,而 reducer 还在等待一个结果 action(LOGIN_SUCCESSLOGIN_ERROR)。

幸运的是,cancel Effect 不会粗暴地结束我们的 authorize 任务,它会在里面抛出一个特殊的错误,给 authorize 一个机会执行它自己的清理逻辑。
而被取消的任务应该捕捉这个错误,假设它需要在结束之前做一些事情的话。

我们的 authorize 已经有一个 try/catch 区块,但它定义了一个通用的处理程序,这个程序会在每次发生错误时发起 LOGIN_ERROR action。
但登录取消并不是错误。所以 authorize 任务必须仅在授权失败时发起 LOGIN_ERROR action。

  1. import { isCancelError } from 'redux-saga'
  2. import { take, call, put } from 'redux-saga/effects'
  3. import Api from '...'
  4. function* authorize(user, password) {
  5. try {
  6. const token = yield call(Api.authorize, user, password)
  7. yield put({type: 'LOGIN_SUCCESS', token})
  8. return token
  9. } catch(error) {
  10. if(!isCancelError(error))
  11. yield put({type: 'LOGIN_ERROR', error})
  12. }
  13. }

你可能已经注意到了,我们仍然没有做任何与清除 isLoginPending 状态相关的事情。
对于这一点,有 2 个可能的解决方案(或其他的)。

  • 发起一个指定的 action RESET_LOGIN_PENDING

  • 或者更简单,让 reducer 收到 LOGOUT action 时清除 isLoginPending

更多内容,敬请期待