Eason

处理异步利器 -- Redux-saga

Eason · 2017-01-26翻译 · 1759阅读 原文链接

freeCodeCamp

几天前,我和同事谈了谈如何管理Redux异步操作。虽然他用了很多插件来扩展Redux,但还是让他对 Javascript 产生疲劳症

我们来看看是什么情况:如果你习惯于根据你的需要而不是根据技术身所带来的价值,来使用技术为你工作,那么搭建React生态系统是非常烦人和浪费时间的。

过去两年,我参与了一些Angular项目,并且爱上了 MVC(Model-View-Controller) 开发模式。有一点我不得不说,对于Backbone.js出身的我来说,学习Angular虽然很困难,但同时也非常值得。正因为如此,我找到了一份更好的工作,也有机会从事自己感兴趣的工作了。说真的,我从Angular社区学到了很多。

这些日子工作非常顺利,但是战斗还要继续,我也在尝试了新的框架: React, Redux 和 Sagas。

几年前,我偶然阅读了一篇Thomas Burleson的文章 -- Promise调用链扁平化,受益良多。两年前,我还经常想起其中很多极好的想法。

这些天我在往React迁移,我发现Redux非常强大,尤其是使用sagas来管理异步操作的时候深有感触。所以我就参考了Thosmas的文章,写下了这篇文章,用redu-saga实现了类似的方法。希望本文能给大家带来帮助,更好地理解学习redux-saga的重要性。

声明: 我也在另一个项目中做了类似的事情,希望在两个项目中都能引发大家讨论。本文中,我假设你已经对 Promise,React,Redux 等 Javascript 知识有了基本的了解。

首先

Redux-saga的作者 Yassine Elouafi 说:

redux-saga 是一个库,致力于在React/Redux应用中简化异步操作(side effects,即像异步获取远程数据或者浏览器缓存数据)。

Redux-saga 是基于 saga 和 ES6 生成器函数(Generator),辅助我们快速组织所有异步、分散的操作。如果你想要了解更多Saga模式本身,可以看看 Caitie McCaffrey 录制的视频;想了解更多关于Generators的知识,可以免费观看 Egghead 上的视频 (至少在本文发布的时候,视频是免费的)。

案例:飞行航班表

本文将用Redux-saga重现Thomas举的例子。代码最终放在 Github 上,并附上demo

场景如下:

图片来自 Thomas Burleson

如你所见,上图中有三个API调用:getDeparture -> getFlight -> getForecast,所以我们的API服务设计如下:

class TravelServiceApi {
 static getUser() {
   return new Promise((resolve) => {
     setTimeout(() => {
       resolve({
            email : "somemockemail@email.com",
            repository: "http://github.com/username"
       });
     }, 3000);
   });
 }

 static getDeparture(user) {
  return new Promise((resolve) => {
   setTimeout(() => {
    resolve({
      userID : user.email,
      flightID : “AR1973”,
      date : “10/27/2016 16:00PM”
     });
    }, 2500);
   });
 }

 static getForecast(date) {
  return new Promise((resolve) => {
      setTimeout(() => {
        resolve({
            date: date,
            forecast: "rain"
        });
      }, 2000);
   });
  }
}

这些API服务是模拟场景中的数据服务。首先,我们需要一个用户的信息,然后根据这个用户的信息去获取航班的起飞信息和天气预报,从而我们创建了多个数据面板如下:

React 组件代码可以在这里找到。这三个组件是不同的,分别对应了三个不同的reducers,如下:

const dashboard = (state = {}, action) => {
 switch(action.type) {
  case ‘FETCH_DASHBOARD_SUCCESS’:
  return Object.assign({}, state, action.payload);
  default :
  return state;
 }
};

由于不同的面板对应不同的reducer,那么如何获取用户信息呢?这里就用到了redux的方法 mapStateToProps:

const mapStateToProps =(state) => ({
 user : state.user,
 dashboard : state.dashboard
});

一切准备就绪(可能我没有讲的很详细,因为我主要想快速引入sagas),我们开始下一步吧。

Sagas出场

William Deming 曾说过:

如果你无法描述你现在做什么,那么你就不算了解你现在做的事情。

Ok,让我们一步步开始,看看 Redux Saga 是如何工作的。

1. 注册 Sagas

我会根据我自己的理解描述Redux Saga中的API。如果你想知道更多技术细节,可以参考 Redux-saga 官方文档

首先,我们需要创建一个 saga generator,并注册到Redux中:

function* rootSaga() {
  yield[
    fork(loadUser),
    takeLatest('LOAD_DASHBOARD', loadDashboardSequenced)
  ];
}

Redux saga 暴露了几个方法,称为 Effects,定义如下:

  • Fork 执行一个非阻塞操作。

  • Take 暂停并等待action到达。

  • Race 同步执行多个 effect,然后一旦有一个完成,取消其他 effect。

  • Call 调用一个函数,如果这个函数返回一个 promise ,那么它会阻塞 saga,直到promise成功被处理。

  • Put 触发一个Action。

  • Select 启动一个选择函数,从 state 中获取数据。

  • takeLatest 意味着我们将执行所有操作,然后返回最后一个(the latest one)调用的结果。如果我们触发了多个时间,它只关注最后一个(the latest one)返回的结果。

  • takeEvery 会返回所有已出发的调用的结果。

这里我们注册了两个不同的 soga,后面我们会补充定义。到目前为止,我们分别用 forktakeLatest 调用了这两个soga,其中takeLatest会暂停直到触发 “LOAD_DASHBOARD” Action。我们会在 step #3 中具体描述。

2. 在Redux store中插入Saga中间件

在我们定义并初始化 Redux store 的时候,我们常常这么做:

const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer, [], compose(
      applyMiddleware(sagaMiddleware)
);
sagaMiddleware.run(rootSaga); /* 将我们的 sagas 插入到这个中间件 */

3. 创建Sagas

首先,我们会定义 loadUser Saga :

function* loadUser() {
  try {
   //1st step
    const user = yield call(getUser);
   //2nd step
    yield put({type: 'FETCH_USER_SUCCESS', payload: user});
  } catch(error) {
    yield put({type: 'FETCH_FAILED', error});
  }
}

以下是我们的理解:

  • 首先,调用异步函数 getUser ,然后将返回结果赋值给 user

  • 然后,触发 FETCH_USER_SUCCESS Action,并将 user 的值传给 store 处理。

  • 如果发生异常,则触发 FETCH_FAILED Action。

正如你所见,我们可以将 yield 操作的结果赋予给一个变量。

接下来,我们创建另一个saga:

function* loadDashboardSequenced() {
 try {
  yield take(‘FETCH_USER_SUCCESS’);

  const user = yield select(state => state.user);

  const departure = yield call(loadDeparture, user);

  const flight = yield call(loadFlight, departure.flightID);

  const forecast = yield call(loadForecast, departure.date);

  yield put({type: ‘FETCH_DASHBOARD_SUCCESS’, payload: {forecast,  flight, departure} });
  } catch(error) {
    yield put({type: ‘FETCH_FAILED’, error: error.message});
  }
}

以下是我对 loadDashboardSequenced saga 的理解:

  • 首先,我们使用 take 来暂停操作,take effect 会一直等待直到dispatch或者put事件触发了 FETCH_USER_SUCCESS Action。

  • 其次,使用 select effect从 Redux staore 中获取 state ,它接受一个函数。这里我们只取了 state.user 的值 。

  • 接着,我们用 call effect 调用异步操作,将 user 作为参数传入,来获取航班起飞信息。

  • 然后,在 loadDeparture 结束后,继续执行 loadFlight

  • 同时需要获取天气信息,但是我们需要等待 loadFlight 结束才会执行下一个 call effect。

  • 最后,一旦所有的操作结束后,我们会使用 put Effect 来触发 FETCH_DASHBOARD_SUCCESS Action,并将整个 saga 中加载的信息作为它的参数。

正如你所见,一个 saga 是 之前一系列数据操作以及触发 action 的步骤的集合。一旦所有的操作结束,所有的信息就会发送给 Redux store 处理。

你现在觉得 saga 处理异步足够优雅吗?

那么,接下来,我们继续考虑另一个问题:是否能同时触发 getFlightgetForecast ?因为它们互不相关,不必等待一方执行,所以我们新的的想法如下图所示:

图片来自 Thomas Burleson

非阻塞 Saga

为了执行两个非阻塞操作,我们需要对之前的 saga 稍作修改:

function* loadDashboardNonSequenced() {
  try {
      // 等待加载用户信息
    yield take('FETCH_USER_SUCCESS');

    // 从 store 中获取 用户信息
    const user = yield select(getUserFromState);

    // 获取航班起飞信息
    const departure = yield call(loadDeparture, user);

    // 魔术时刻,见证奇迹的时候到了
    const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];

    // 告诉 store 我们准备好了
    yield put({type: 'FETCH_DASHBOARD2_SUCCESS', payload: {departure, flight, forecast}});
} catch(error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

这里我们 yield 一个数组:

const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];

因此数组中的这两个操作是并行执行的,但如果有需要,我们可以等待两个操作都结束再触发UI更新。

然后,我们需要在 rootSaga 中注册 新的 saga :

function* rootSaga() {
  yield[
    fork(loadUser),
    takeLatest('LOAD_DASHBOARD', loadDashboardSequenced),
    takeLatest('LOAD_DASHBOARD2' loadDashboardNonSequenced)
  ];
}

一旦操作完成,我们就需要更新UI吗 ?

我知道你现在还无法回答这个问题,不过别担心,一会儿我们会给你一个正确答案。

非序列化(Non-Sequenced)且非阻塞(Non-Blocking) Sagas

我们既可以合并这两个 saga:Flight Saga 和 Forecast Saga,也可以将它们分开,也就是说它们是独立的。而这点正是我们需要的。下面让我们看看如何操作:

Step #1:分离 Forecast 和 Flight Saga。它们都依赖航班起飞信息(departure)。

/* **************Flight Saga************** */
function* isolatedFlight() {
  try {
      /* departure 会拿到 put 传过来的值,也就是 一个完整的 redux action 对象 */
    const departure = yield take('FETCH_DEPARTURE3_SUCCESS');

    const flight = yield call(loadFlight, departure.flightID);

    yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: {flight}});
  } catch (error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

/* **************Forecast Saga************** */
function* isolatedForecast() {
    try {
      /* departure 会拿到 put 传过来的值,也就是 一个完整的 redux action 对象 */
     const departure = yield take('FETCH_DEPARTURE3_SUCCESS');

     const forecast = yield call(loadForecast, departure.date);

     yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { forecast, }});
} catch(error) {
      yield put({type: 'FETCH_FAILED', error: error.message});
    }
}

这里有些非常重要的概念,你注意到了吗?这些概念构成了我们的 sagas :

  • 这两个 saga 在等待同一个 Action Event (FETCH_DEPARTURE3_SUCCESS)的触发。

  • 在这个事件(event)被触发时,它们都会获得航班起飞信息。更多细节见 Step #2 。

  • 它们都会使用 call Effect 执行自己的异步操作,并且异步操作结束后会触发相同的事件。但是他们发送不同的数据到 store 中处理。多亏了强大的 Redux ,我们这样做不用改变原来的 reducer。

Step #2:下面,我们稍微修改下 departure saga 以确保它将起飞信息发个另外两个 saga 。

function* loadDashboardNonSequencedNonBlocking() {
  try {
      // 等待 FETCH_USER_SUCCESS Action
    yield take('FETCH_USER_SUCCESS');

    // 从 store 中获取用户信息
    const user = yield select(getUserFromState);

    // 获取航班起飞信息
    const departure = yield call(loadDeparture, user);

    // 发出action,更新 store,并触发UI更新
    yield put({type: 'FETCH_DASHBOARD3_SUCCESS', payload: { departure, }});

    // 发出 FETCH_DEPARTURE3_SUCCESS Action,触发 Forecast 和 Flight Saga
    // 我们可以在 put 操作中 发生一个对象
    yield put({type: 'FETCH_DEPARTURE3_SUCCESS', departure});
  } catch(error) {
    yield put({type: 'FETCH_FAILED', error: error.message});
  }
}

put Effect 之前,所有的代码都和之前一样。我最喜欢的一点就是,put Effect 很容易将数据作为Action的Payload,发送到 Forecast 和 Flight saga。

你可以看看 demo,看看是第三个panel是如何在加载航班信息前获取天气预报的,需要注意的是,获取航班信息只是模拟了耗时的请求。

在实际应用中,可能我的操作会有些不同,不是模拟请求而是实际请求。这里我只想说明 put Effect 的价值,你可以很方便的使用 put 传值。

关于测试

你也做测试吧?

Sagas是非常容易测试的,但是它们需要结合你的步骤,手动使用 generators 一步一步操作。下面,我们看一个例子。(代码地址)

describe('Sequenced Saga', () => {
  const saga = loadDashboardSequenced();
  let output = null;

it('should take fetch users success', () => {
      output = saga.next().value;
      let expected = take('FETCH_USER_SUCCESS');
      expect(output).toEqual(expected);
  });

it('should select the state from store', () => {
      output = saga.next().value;
      let expected = select(getUserFromState);
      expect(output).toEqual(expected);
  });

it('should call LoadDeparture with the user obj', (done) => {
    output = saga.next(user).value;
    let expected = call(loadDeparture, user);
    done();
    expect(output).toEqual(expected);
  });

it('should Load the flight with the flightId', (done) => {
    let output = saga.next(departure).value;
    let expected = call(loadFlight, departure.flightID);
    done();
    expect(output).toEqual(expected);
  });

it('should load the forecast with the departure date', (done) => {
      output = saga.next(flight).value;
      let expected = call(loadForecast, departure.date);
      done();
      expect(output).toEqual(expected);
    });

it('should put Fetch dashboard success', (done) => {
       output = saga.next(forecast, departure, flight ).value;
       let expected = put({type: 'FETCH_DASHBOARD_SUCCESS', payload: {forecast, flight, departure}});
       const finished = saga.next().done;
       done();
       expect(finished).toEqual(true);
       expect(output).toEqual(expected);
    });
});
  1. 确保你引入了所有 effect 和 待测试的方法。

  2. 当你需要使用 yield 存储一个值到 store 的时候,你需要将模拟数据传给 next 方法。就如测试 3,4,5。

  3. 然后,在 next 方法被调用后,每个 generator 移动到下一个 yield 操作。这就是为什么我们要使用 saga.next().value

  4. 这一系列测试是确定的。一般来说,如果你改变了 saga 的操作,测试是无法通过的。

总结

我非常乐意尝试新技术,并且我们会发现,前端开发几乎每天都会有新东西产生。一旦某个技术被社区接受,就会有很多人想使用它,这对于开发者来说是非常酷的。有时我会从这些新技术中学到很多,但是更重要的是,考虑一下是否我们真的需要它。

我知道 Redux-Thunk 是更容易实现和维护的。但是对于复杂的操作,尤其是面对复杂异步操作时,Redux-Saga 更有优势。

最后,感谢 Thomas 的文章给我带来灵感。我希望大家也能从我的这篇文章中受到启发。

如果你有任何问题,欢迎[联系我]http://twitter.com/andresmijares25),我非常乐意提供帮助。

译者Eason尚未开通打赏功能

相关文章