最近项目用了dva,dva对于异步action的处理是用了redux-saga,故简单学习了下redux-saga;
目录
是什么
redux-saga 就是 redux 的一个中间件,用于更优雅地管理副作用(side effects);
redux-saga可以理解为一个 和 系统交互的 常驻进程,可简单定义:
saga = Worker + Warcher
名词解释
- side effects
Side effects are the most common way that a program interacts with the outside world (people, filesystems, other computers on networks) [from Wikipedia]
副作用是 程序与外部世界(人、文件系统,网络上的其他计算机) 交互的最常用的方式。
映射到前端, 副作用一般指异步网络请求。
- Effect
An effect is a plain JavaScript Object containing some instructions to be executed by the saga middleware.
effect 是一个普通的 javascript对象,包含一些指令,这些指令最终会被 redux-saga 中间件 解释并执行。
在 redux-saga 世界里,所有的 Effect 都必须被 yield 才会执行
原则上来说,所有的 yield 后面也只能跟Effect,以保证代码的易测性。
eg:
yield fetch(UrlMap.fetchData);
应该用 call Effect :
yield call(fetch, UrlMap.fetchData)
- task
task 是 generator 方法的执行环境,所有saga的generator方法都跑在task里。
为什么
作用
用于更优雅地管理副作用, 在前端就是异步网络请求;本质就是为了解决异步action的问题;
优点
副作用转移到单独的saga.js中,不再掺杂在action.js中,保持 action 的简单纯粹,又使得异步操作集中可以被集中处理。对比redux-thunk
redux-saga 提供了丰富的 Effects,以及 sagas 的机制(所有的 saga 都可以被中断),在处理复杂的异步问题上更顺手。提供了更加细腻的控制流。
对比thunk,dispatch 的参数依然是一个纯粹的 action (FSA)。
每一个 saga 都是 一个 generator function,代码可以采用 同步书写 的方式 去处理 异步逻辑(No Callback Hell),代码变得更易读。
同样是受益于 generator function 的 saga 实现,代码异常/请求失败 都可以直接通过 try/catch 语法直接捕获处理。
怎么用
hello saga
- 单独的文件:sagas.js, 统一管理副作用:
export function* helloSaga() {
console.log('Hello Sagas!');
}
- 将saga和store关联起来, 入口文件 main.js:
import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
import helloSaga from './sagas'
import rootReducer from './reducers'
// 创建 saga middleware
const sagaMiddleware = createSagaMiddleware();
// 创建 store
const store = createStore(
rootReducer,
applyMiddleware(sagaMiddleware) // 注入 saga middleware
);
// 启动 saga
sagaMiddleware.run(helloSaga);
// 省略ReactDOM.render部分的代码...
这时候就可以看到Hello Sagas!
了;
代码分析:
line 8 :通过redux-saga提供的工厂函数 createSagaMiddleware 创建 sagaMiddleware(当然创建时,你也可以传递一些可选的配置参数)。
line 11-14 : 创建 store 实例, 并注入 saga中间件。意味着:之后每次执行 store.dispatch(action),数据流都会经过 sagaMiddleware 这一道工序,进行必要的 “加工处理”(比如:发送一个异步请求)。
line 17 : 启动 saga,调用run方法使得generator可以开始执行,也就是执行 rootSaga。通常是程序的一些初始化操作(比如:初始化数据、注册 action 监听)。
3、接下来加入异步调用的流程
先看下要实现的效果:
省略UI代码;
reducer中已有加一的处理:
...
case 'INCREMENT':
return {
...state,
count: state.count + 1
}
...
sagas.js:
import { all, put, takeEvery } from 'redux-saga/effects'
const delay = (ms) => new Promise(res => setTimeout(res, ms))
// worker Saga: 执行异步的 increment 任务
export function* incrementAsync() {
yield delay(1000) // middleware 拿到一个 yield 后的 Promise,暂停1s后再继续执行
yield put({ type: 'INCREMENT' }) // 告诉 middleware 发起一个 INCREMENT 的 action。
}
// watcher Saga: 在每个INCREMENT_ASYNC上生成一个新的incrementAsync任务
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}
// 启动saga们
export default function* rootSaga() {
yield all([
watchIncrementAsync(),
helloSaga()
])
}
把saga和store联系起来的代码和上面相似,就是把helloSaga替换成rootSaga即可;
代码分析:
Sagas 是被实现为 Generator functions 的
line 2 : 创建一个delay
函数,返回一个Promise,它在指定的毫秒数后解析。
line 5-8 : incrementAsync 这个 Saga 会暂停,直到 delay 返回的 Promise 被 resolve,即 1000ms 之后;
line 6 : middleware 拿到一个 yield 后的 Promise,middleware 暂停 Saga,直到 Promise 完成。一旦 Promise 被 resolve,middleware 会恢复 Saga 接着执行,直到遇到下一个 yield。
line 7 : 这里就是第二个yield啦,这里的put({type: 'INCREMENT'})
就是一个Effect,Effect 是纯js对象,其中包含了给 middleware 执行的指令;当 middleware 拿到被Saga yield的Effect的时候,也会暂停Saga,直到Effect 执行完成,然后Saga 会再次被恢复。
line 11-13 : 写一个watcher saga,用redux-saga的apitakeEvery
来监听所有的 INCREMENT_ASYNC action,并在 action 被匹配时执行 incrementAsync 任务。
line 15-18 : 有了Saga,,现添加一个rootSaga来负责启动所有Saga,用了all
api,如果有其他Saga都能一起启动。
line 7 返回的是一个Effect,console('Effect', put({ type: 'INCREMENT' }))
:
基于redux的数据流:
状态决定展现,交互就是改状态
基于redux-saga的一次完整单向数据流:
api
在第一次使用dva的时候,用的最多的api就是
put
和call
,有时还有用select
❀Effect 创建器(creators)
1、put(action)
创建一个Effect描述信息,指示 middleware 向Store dispatch一个action
相当于在 saga 中调用 store.dispatch(action)。
2、select(selector, ...args)
创建一个Effect,指示 middleware 调用提供的选择器获取 Store state 上的数据,即获取状态
3、call(fn, ...args)
创建一个Effect描述信息,指示 middleware 以args为参数调用fn;
即执行fn(...args);
如果fn是个Generator,或者返回Promise,那么会阻塞当前 saga 的执行,直到被调用函数 fn 返回结果,才会执行下一步代码。
4、take(pattern)
创建一个Effect描述信息,指示 middleware 等待 Store 上指定的 action。 Generator 会暂停(被阻塞了),直到一个与 pattern 匹配的 action 被发起。
有种事件监听的感觉。
take的返回值是action
如果调用take而没有参数或是'*',则所有调度的操作都匹配(例如,take()
将匹配所有操作)
可以监听多个,eg:
yield take(['LOGOUT', 'LOGIN_ERROR'])
5、fork(fn, ...args)
创建一个Effect描述信息,指示 middleware 以 无阻塞调用 方式执行 fn
fork的返回值是task
类似于 call effect,区别在于它不会阻塞当前 saga,如同后台运行一般,会立即返回一个 task 对象。
yield fork(fn ...args)
的结果是一个 Task 对象 —— 具有一些有用方法和属性的对象。
6、cancel(task)
创建一个Effect描述信息,针对 fork 方法返回的 task ,可以进行取消关闭。
7、cancelled()
创建一个Effect描述信息,指示 middleware 返回 该 generator 是否已经被取消。通常你会在 finally 区块中使用这个 Effect 来运行取消时专用的代码。
❀在强大的低阶 API 之上构建的 wrapper effect
8、takeEvery(pattern, saga, ...args)
被 dispatch 的 action 中,在匹配到 pattern 的每一个 action 上派生一个 saga
takeEvery 是一个使用 take 和 fork 构建的高级 API。
实现:
const takeEvery = (patternOrChannel, saga, ...args) => fork(function*() {
while (true) {
const action = yield take(patternOrChannel)
yield fork(saga, ...args.concat(action))
}
})
9、takeLastest(pattern, saga, ...args)
被 dispatch 的 action 中,在匹配 pattern 的每一个 action 上派生一个 saga。并自动取消之前所有已经启动但仍在执行中的 saga 任务。
takeLatest 也是一个使用 take 和 fork 构建的高级 API。
实现:
const takeLatest = (patternOrChannel, saga, ...args) => fork(function*() {
let lastTask
while (true) {
const action = yield take(patternOrChannel)
if (lastTask) {
yield cancel(lastTask) // 如果任务已经结束,cancel 则是空操作
}
lastTask = yield fork(saga, ...args.concat(action))
}
})
❀Effect 组合器(combinators)
10、race([...effects])
创建一个Effect描述信息,指示 middleware 在多个 Effect 之间运行一个 race(与 Promise.race([...]) 的行为类似)。
race可以取到最快完成的那个结果,常用于请求超时
11、all([...effects])
创建一个 Effect 描述信息,指示 middleware 并行运行多个 Effect,并等待它们全部完成。这是与标准的Promise#all相对应的 API。
也可用[...effects]
,yield 一个包含 effects 的数组,eg:
import { call } from 'redux-saga/effects'
// 正确写法, effects 将会同步执行
const [users, repos] = yield [
call(fetch, '/users'),
call(fetch, '/repos')
];
generator 会被阻塞直到所有的 effects 都执行完毕,或者当一个 effect 被拒绝 (就像 Promise.all 的行为)。
欲了解其他api可以访问: 速查直达
在dva中使用
对于try catch的额外补充
Call vs Fork
saga 中 call 和 fork 都是用来执行指定函数 fn,区别在于:
- call Effect 会阻塞当前 saga 的执行,直到被调用函数 fn 返回结果才执行下一步代码。
- fork Effect 则不会阻塞当前 saga,会立即返回一个 task 对象。
fork 的异步非阻塞特性更适合于在后台运行一些不影响主流程的代码
高级概念
1、监听未来的action —— take
先看下要实现的效果:
take的实现:
import { select, take } from 'redux-saga/effects'
function* watchAndLog() {
while (true) {
const action = yield take('*');
const state = yield select();
console.log('action', action);
console.log('state after', state);
}
}
代码分析:
这是一个简单的打印日志功能
line 5: 指示 middleware 等待一个特定的 action。这里整个Generator被暂停了,直到匹配到的action被dispatch了,这里是,所以是任意一个action; yield take('')的返回值就是匹配到的action
line 6: 用select api 拿到所有状态
line 4-9: 这里用了while(true)
,因为 Generator 函数不具备 从运行至完成 的行为(run-to-completion behavior),这个Generator 会每次迭代到第5行时阻塞,以等待 action 发起。
对比takeEvery
,实现一样的效果:
import { select, takeEvery } from 'redux-saga/effects'
function* watchAndLog() {
yield takeEvery('*', function* logger(action) { // 这里action被被动注入回调了
const state = yield select()
console.log('action', action)
console.log('state after', state)
})
}
可以看出,takeEvery 的实现中, 匹配到action就执行回调, action就被动的被 push 到任务处理函数的。
每次 action 被匹配时任务处理函数就会一遍又一遍地被调用。并且它们也无法控制何时停止监听。
而 take 的实现中,Saga 是自己主动 pull action 的,就像是在执行一个普通函数一样:action = getNextAction()
。主动拿到action就可以控制停止,流程上更灵活;
eg:
监听用户的操作,并在用户初次创建完三条 Todo 信息时显示祝贺信息
import { take, put } from 'redux-saga/effects'
function* watchFirstThreeTodosCreation() {
for (let i = 0; i < 3; i++) {
const action = yield take('TODO_CREATED')
}
yield put({type: 'SHOW_CONGRATULATION'})
}
action被匹配到3次之后,Generator 会被回收并且相应的监听不会再发生
主动拉取 action 可以让我们使用熟悉的同步风格来描述我们的控制流
eg:
监听得来,还有顺序
function* loginFlow() {
while (true) {
yield take('LOGIN')
// ... perform the login logic
yield take('LOGOUT')
// ... perform the logout logic
}
}
2、无阻塞调用 —— fork
登录流程案例
就着上面说的登录登出流程,先提前看一段代码(有问题的):
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'))
}
}
}
line 16: 当 LOGIN_REQUEST 的action被匹配时,拿到用户名密码就去调用 authorize 这个Generator
(PS: call 不仅可以用来调用返回 Promise 的函数。我们也可以用它来调用其他 Generator 函数。 )
line 4-12: 拿到用户名密码之后就去执行真正的请求,这时候 authorize 就被阻塞了,等待着拿token;拿到 token 就 dispatch 登录成功,返回token;登录失败就 dispatch 登录失败
line 18-22: 登录成功之后就缓存token,并且监听登出的action,当匹配LOGOUT,则清楚token
上面的代码流程很清晰,就像阅读同步代码一样,自然顺序确定了执行步骤,不用专门理解控制流(如果用takeEvery就会需要去理解)
但是,上面的代码有问题。
当用户点登录之后,authorize 被阻塞,请求还没返回,token还没拿到,就在此刻,用户又点了登出,那么...
上面代码的问题是 call 是一个会阻塞的 Effect。即 Generator 在调用结束之前不能执行或处理任何其他事情,然后,LOGOUT 与调用 authorize 是 并发的,导致出问题了
所以,需要本小节的主角登场 —— ☆ fork ☆
fork 一个 任务,任务会在后台启动,Generator不会被阻塞,调用者可以继续它自己的流程,而不用等待被 fork 的任务结束。
具体改进如下:
import { fork, call, take, put, cancel } 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})
yield call(Api.storeItem, {token})
} catch(error) {
yield put({type: 'LOGIN_ERROR', error})
} finally {
if (yield cancelled()) {
// 取消task之后的操作,比如取消loading之类
}
}
}
function* loginFlow() {
while (true) {
const {user, password} = yield take('LOGIN_REQUEST')
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 Object.
line 21: 改用 fork api 调用 authorize ,loginFlow 就不会被阻塞
line 22:监听 2 个并发的 action,
line 22-26: 会有三种情况:
1、在登出之前,token已经拿到了,那么会 dispatch LOGIN_SUCCESS,就结束了,就算在登出流程也是正常的
2、在登出之前,登录失败了,那么会 dispatch LOGIN_ERROR ,然后清除token,结束;进入另外一个 while 迭代等待下一个 LOGIN_REQUEST
3、token还没拿到,用户就登出了,那 loginFlow 会匹配到 LOGOUT ,取消掉 authorize 处理进程,清除token,然后就等待下一个 LOGIN_REQUEST 了
line 8: 使用 fork 之后就拿不到token了,因为不应该等待它,所以将 token 存储操作移到 authorize 任务内部了
line 11-15:如果task被取消之后,你还需要做一些操作,比如Loading本来是true的,你想改成false,那可以利用canceled
这个api来确定是否取消了
3、在多个 Effects 之间启动 race
eg:
触发一个远程的获取请求,并且限制了 1 秒内响应,否则作超时处理
import { race, call, put } from 'redux-saga/effects'
import { delay } from 'redux-saga'
function* fetchPostsWithTimeout() {
const {posts, timeout} = yield race({
posts: call(fetchApi, '/posts'),
timeout: call(delay, 1000)
})
if (posts) {
put({type: 'POSTS_RECEIVED', posts})
} else {
put({type: 'TIMEOUT_ERROR'})
}
}
4、通过yield*
进行排序
function* playLevelOne() { ... }
function* playLevelTwo() { ... }
function* playLevelThree() { ... }
function* game() {
// 利用 yield* 组织saga的顺序
const score1 = yield* playLevelOne() // ※
yield put(showScore(score1))
const score2 = yield* playLevelTwo() // ※
yield put(showScore(score2))
const score3 = yield* playLevelThree() // ※
yield put(showScore(score3))
}
更多高级概念,可直达这里学习
对比redux-thunk
一般情况下,action
都是符合 FSA 标准的(即:a plain javascript object),如下:
{
type: 'ADD_TODO',
payload: {
text: 'Do something.'
}
}
含义:当执行dispatch(action)
时,通知reducer
,并且把action.payload (新状态数据)
以action.type
的方式(操作) 同步更新 到本地store。
但是,涉及请求的时候,payload一般来自于远程服务端;然后redux-thunk就以 middleware 的形式来增强 redux store 的 dispatch 方法,(即支持 dispatch(function)
),看下面代码:
// action.js
// -----------------
// 符合 FSA 的 action
export const setReplyModalData = (data) => {
return { type: SET_REPLY_MODAL_DATA, payload:{data} };
};
// 这个 action return 的是一个function
// function 中包含了业务数据请求代码逻辑
export function fetchData(someValue) {
return (dispatch, getState) => {
myAjaxLib.post("/someEndpoint", { data: someValue })
.then(response => dispatch({ type: "REQUEST_SUCCEEDED", payload: response })
.catch(error => dispatch({ type: "REQUEST_FAILED", error: error });
};
}
// component.js
// ------------
// View 层 dispatch(fn) 触发异步请求
// 这里省略部分代码
this.props.dispatch(fetchData({ hello: 'saga' }));
同样的代码,redux-saga的实现:
它单独起一个新文件saga.js,然后把异步action迁移到里面
// saga.js
// ----------
// worker saga
// 它是一个 generator function
// function 中也包含了业务数据请求代码逻辑,但 是同步的写法
function* fetchData(action) {
const { payload: { someValue } } = action;
try {
const result = yield call(myAjaxLib.post, "/someEndpoint", { data: someValue });
yield put({ type: "REQUEST_SUCCEEDED", payload: response });
} catch (error) {
yield put({ type: "REQUEST_FAILED", error: error });
}
}
// watcher saga
// 监听每一次 dispatch(action)
// 如果 action.type === 'REQUEST',那么执行 fetchData
export function* watchFetchData() {
yield takeEvery('REQUEST', fetchData);
}
// component.js
// -------
// View 层 dispatch(action) 触发异步请求
// 这里的 action 依然可以是一个 plain object
this.props.dispatch({
type: 'REQUEST',
payload: {
someValue: { hello: 'saga' }
}
});
// action.js
// 然后action里就保持了所有都是符合FSA的action了,更干净
export const setReplyModalData = (data) => {
return { type: SET_REPLY_MODAL_DATA, payload:{data} };
};
综上,redux-saga对比redux-thunk的优点:
副作用转移到单独的saga.js中,不再掺杂在action.js中,保持 action 的简单纯粹,又使得异步操作集中可以被集中处理。
dispatch 的参数依然是一个纯粹的 action (FSA),而不是充满 “黑魔法” thunk function。
每一个 saga 都是 一个 generator function,代码采用 同步书写 的方式来处理 异步逻辑(No Callback Hell),代码变得更易读
受益于 generator function 的 saga 实现,代码异常/请求失败 都可以直接通过 try/catch 语法直接捕获处理。
额外补充
请求都要加上try catch,多考虑,避免网页挂掉;
那什么时候写具体的catch呢?
感觉 如果是要获取数据的时候最好写清楚catch,因为这种情况下后端的toast一般都是网络请求失败这种mw,免得拿不到数据就啥也做不了了,这时,作为前端,可以给到用户一个友好的toast。如果后端没返回errmsg,页面也没任何提示就特别不友好如果是创建、编辑、新增等,就不需要前端去做toast,在接口统一处去toast后端返回的error message,才可以toast具体的原因,比如群组重名了(这个是需要后端去查库的)?还是什么数据不合法?还是其他原因,就是提交类型的接口,在前端能做的表单校验完成之后,还是有接口报错,那是后端才能检查出来的,就toast后端的抛出来的问题;
最后总结一下
- redux-saga就是一个redux的中间件,用于更优雅的管理异步
- redux-saga有一堆的api可供使用
- 可以利用同步的方式处理异步逻辑,便于捕获异常,易于测试;