Redux-Saga中的错误捕获之call/fork/spawn

2,375 阅读3分钟

在 saga 中,无论是请求失败,还是代码异常,均可以通过 try catch 来捕获。

Call vs Fork

call 是阻塞任务,fork 是非阻塞任务。

function* errorSaga() {
  throw new Error('模拟异常');
}

function* saga() {
  try {
    yield call(errorSaga);
  } catch(e) {
    console.err(e);
  }
}
// 调用saga

try catch包裹一个call调用的fn,那么这个fn里抛出错误时,是被捕获到的

function* errorSaga() {
  throw new Error('模拟异常');
}

function* saga() {
  try {
    yield fork(errorSaga);
  } catch(e) {
    console.err(e);
  }
}
// 调用saga

try catch包裹一个fork调用的fn,那么这个fn里抛出错误时,是不会被捕获到的。
原因在于,fork 出的 childtask,与执行 fork 的 parentTask 之间虽然是父子关系,但 childtask 是异步的,所以没办法捕获到。如果想捕获 fork 的错误,需要从 parentTask 入手

function* errorSaga() {
  throw new Error('模拟异常');
}

function* saga() {
  try {
    yield fork(errorSaga);
  } catch(e) { --> 这里不会捕获到,其效果与直接调用 yield fork(errorSaga); 一致
    console.err(e); -->
  }
}

function* root() {
  try {
    yield call(saga)
  } catch(e) { --> 这里会捕获到 errorSaga 的错误
    console.err(e); 
  }
}
// 调用root

也就是说,阻塞调用 parentTask 时,是能够捕获到 非阻塞的 childtask错误的。那如果非阻塞调用 parentTask 呢?

function* errorSaga() {
  throw new Error('模拟异常');
}

function* saga() {
  try {
    yield fork(errorSaga);
  } catch(e) { --> 这里不会捕获到,其效果与直接调用 yield fork(errorSaga); 一致
    console.err(e); -->
  }
}

function* root() {
  try {
    yield fork(saga)
  } catch(e) { --> 这里不会捕获到 errorSaga 的错误!!!
    console.err(e); 
  }
}
// 调用root

Fork vs Spawn

Spawn 类似 fork,是非阻塞的,但Spawn出的 task1是一个独立的任务,与执行Spawntask2 之间不是父子关系,task1 属于顶层任务

function* errorSaga() {
  throw new Error('模拟异常');
}

function* saga() {
  yield spawn(errorSaga);
}

function* root() {
  try {
    yield call(saga)
  } catch(e) { --> 这里不会捕获到 errorSaga 的错误
    console.err(e); 
  }
}
// 调用root

未捕获 Error 场景

function* saga1 () { /* ... */ }
function* saga2 () { throw new Error('模拟异常'); }
function* saga3 () { /* ... */ }

function* rootSaga() {
  yield fork(saga1);
  yield fork(saga2);
  yield fork(saga3);
}

// 启动 saga
sagaMiddleware.run(rootSaga);

假设 saga2 出现代码异常了,且没有进行异常捕获,这样的异常会导致整个 Web App 崩溃么?答案是:肯定的!

解释如下: redux-saga 中执行 sagaMiddleware.run(rootsaga)fork(saga) 时,均会返回一个 task 对象(上文中说到),嵌套的 task 之间会存在 父子关系, 就比如上述代码:

  • rootSaga 生成了 rootTask。
  • saga1,saga2 和 saga3,在 rootSaga 内部执行,生成的 task,均被认为是 rootTask 的 childTask。

现在某一个 childTask 异常了(比如这里的: saga2),那么它的 parentTask(如:rootTask)收到通知先会执行自身的 cancel 操作,再通知其他 childTask(如:saga1,saga3) 同样执行 cancel 操作。(这其实正是 Saga Pattern 的思想)

但这就意味着,用户可能会因为一个按钮点击引发的异常,而导致整个 Web 应用的功能均无法使用!!

那么,面对这样的问题,如何优化呢?隔离 childTask 是首先想到的一种方案。

export default function* root() {
  yield spawn(saga1);
  yield spawn(saga2);
  yield spawn(saga3);
}

使用 spawn 替换 fork,它们的区别在于 spawn 返回 isolate task,是一个顶层 task,不存在 父子关系,也就是说,即使 saga2 挂了,rootSaga 也不受影响,saga1 和 saga3 自然更不会受影响,依然可以正常工作。

Error 捕获方案

通过在最上层为每一个 childSaga 添加异常捕获,并通过 while(true) {} 循环自动创建新的 childTask 取代 异常 childTask,以保证功能依然可用(这就类似于 Egg 中某一个 woker 进程 挂了,自动重启一个新的 woker 进程一样)

function* rootSaga () {
  const sagas = [ saga1, saga2, saga3 ]; 

  yield sagas.map(saga =>
    spawn(function* () {
      while (true) {
        try {
          yield call(saga);
        } catch (e) {
          console.log(e);
        }
      }
    })
  );
}