译者:Eason
几天前,我和同事谈了谈如何管理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 的文章给我带来灵感。我希望大家也能从我的这篇文章中受到启发。
如果你有任何问题,欢迎[联系我]twitter.com/andresmijar…