处理异步利器 -- Redux-saga
几天前,我和同事谈了谈如何管理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,后面我们会补充定义。到目前为止,我们分别用 fork 和takeLatest 调用了这两个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 处理异步足够优雅吗?
那么,接下来,我们继续考虑另一个问题:是否能同时触发 getFlight 和 getForecast ?因为它们互不相关,不必等待一方执行,所以我们新的的想法如下图所示:
图片来自 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);
});
});
确保你引入了所有 effect 和 待测试的方法。
当你需要使用 yield 存储一个值到 store 的时候,你需要将模拟数据传给 next 方法。就如测试 3,4,5。
然后,在 next 方法被调用后,每个 generator 移动到下一个 yield 操作。这就是为什么我们要使用 saga.next().value 。
这一系列测试是确定的。一般来说,如果你改变了 saga 的操作,测试是无法通过的。
总结
我非常乐意尝试新技术,并且我们会发现,前端开发几乎每天都会有新东西产生。一旦某个技术被社区接受,就会有很多人想使用它,这对于开发者来说是非常酷的。有时我会从这些新技术中学到很多,但是更重要的是,考虑一下是否我们真的需要它。
我知道 Redux-Thunk 是更容易实现和维护的。但是对于复杂的操作,尤其是面对复杂异步操作时,Redux-Saga 更有优势。
最后,感谢 Thomas 的文章给我带来灵感。我希望大家也能从我的这篇文章中受到启发。
如果你有任何问题,欢迎[联系我]http://twitter.com/andresmijares25),我非常乐意提供帮助。