无阻塞调用

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

重温一下登录流程示例:

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

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

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

初次尝试

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

让我们试试吧:

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

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 action 来启动一个新的迭代。

loginFlow 首先等待一个 LOGIN_REQUEST action。 然后在 action 的 payload 中获取有效凭据(即 user 和 password)并调用一个 call 到 authorize 任务。

正如你注意到的,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:

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

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

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

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

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

为了表示无阻塞调用,redux-saga 提供了另一个 Effect: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 的 action 在后台启动之后,我们获取不到 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})
  } 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 个并发的 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。

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 action,我们将那个 task 传入给 cancel Effect。 如果任务仍在运行,它会被中止。如果任务已完成,那什么也不会发生,取消操作将会是一个空操作(no-op)。最后,如果该任务完成了但是有错误, 那我们什么也没做,因为我们知道,任务已经完成了。

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

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

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

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

import { isCancelError } from 'redux-saga'
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) {
    if(!isCancelError(error))
      yield put({type: 'LOGIN_ERROR', error})
  }
}

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

  • 发起一个指定的 action RESET_LOGIN_PENDING。

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

更多内容,敬请期待

results matching ""

    No results matching ""