關於 redux side effects
在開發前端 SPA 的時候,我們有一大半的時間再處理異步動作,更具體來說的話就是網路請求(ajax)。
常見的使用者流程
store.subscribe(() => {
const action = store.getState().lastAction;
switch (action.type) {
case START_REQUEST: {
fetch('/api/v1/foo')
.then(response => response.json())
.then((data) => {
store.dispatch(receive(data));
})
.catch((error) => {
store.dispatch(showNotification(error));
});
break;
}
}
})
處理從請求開始到請求結束的流程在 redux only 下,我們可以透過 store.subscribe 去監聽 actions,然後進一步的調用異步方法,並在異步方法結束的時候 dispatch 相對應的 action。
- lastAction 需要自己在 reducer 內實作,可以參考到 https://github.com/reactjs/redux/issues/580
const middleware = (store) => {
return next => action => {
// Dispathcing action.
const returnValue = next(action);
// Action dispatched.
const state = store.getState();
switch (action.type) {
case START_REQUEST: {
fetch('/api/v1/foo')
.then(response => response.json())
.then((data) => {
store.dispatch(receive(data));
})
.catch((error) => {
store.dispatch(showNotification(error));
});
break;
}
}
}
};
const store = createStore(
rootReducer,
applyMiddleware(middleware),
);
註冊為 middleware
但是很明顯,這樣非常糟
redux 異步處理 middleware 的開拓者
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => next => action => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
source code 極短
Action in, action out
const pingEpic = action$ =>
action$.filter(action => action.type === 'PING')
.mapTo({ type: 'PONG' });
// later...
dispatch({ type: 'PING' });
到這邊 Rx 的缺點:需要的入門門檻有點高就悄悄的冒出來了
以 ES6 generator 為核心主軸,利用 yield 和 redux saga 所提供的一些 helpers 來處理 async actions。
import { call, put } from 'redux-saga/effects'
export function* fetchData(action) {
try {
const data = yield call(Api.fetchUser, action.payload.url)
yield put({type: "FETCH_SUCCEEDED", data})
} catch (error) {
yield put({type: "FETCH_FAILED", error})
}
}
本體是 generator
Compare side by side
跟哈味一樣,有人喜歡有人不喜歡
Launch Up (redux-saga)
import { takeEvery } from 'redux-saga';
import { call, put } from 'redux-saga/effects';
// Watcher
function* watchFetchData() {
yield* takeEvery('FETCH_REQUESTED', fetchData);
}
// Worker
function* fetchData(action) {
try {
const data = yield call(fetch, action.payload.url);
yield put({type: 'FETCH_SUCCEEDED', data});
} catch (error) {
yield put({type: 'FETCH_FAILED', error});
}
}
+export default function* rootSaga() {
+ yield all([
+ fork(watchFetchData),
+ ]);
+}
Watcher + Worker
import 'rxjs';
import { combineEpics } from 'redux-observable';
// Use RxJS ajax
const fetchEpic = (action$) =>
action$.ofType('FETCH_REQUESTED')
.mergeMap(action => Observable.ajax.get(action.payload.url))
.map(data => ({ type: 'FETCH_SUCCEEDED', data }))
.catch(error => Observable.of({ type: 'FETCH_FAILED', error }));
// Or simply use fetch
// const fetchEpic = (action$) =>
// action$.ofType('FETCH_REQUESTED')
// .mergeMap(fetch(action.payload.url).then(response => response.json()));
// .map(data => { type: 'FETCH_SUCCEEDED', data })
// .catch(error => Observable.of({ type: 'FETCH_FAILED', error }));
+export default combineEpics(
+ fetchEpic,
+);
Launch Up (redux-observable)
Type & Observable
import axios, { CancelToken } from 'axios';
import { CANCEL } from 'redux-saga';
function* watchFetchData() {
while (take('FETCH_REQUESTED')) {
const fetchTask = yield fork(fetchData);
yield take('CANCEL_FETCH');
yield cancel(fetchTask);
}
}
function* fetchData(action) {
try {
const data = yield call(fetchAPI, action.payload.url);
yield put({type: 'FETCH_SUCCEEDED', data});
} catch (error) {
yield put({type: 'FETCH_FAILED', error});
}
}
// Wrap axios
function fetchAPI(url) {
const source = CancelToken.source();
const request = axios.get(url);
request[CANCEL] = () => source.cancel();
return request;
}
Cancelable (redux-saga)
// Use RxJS ajax
const fetchEpic = (action$) =>
action$.ofType('FETCH_REQUESTED')
.mergeMap(action => {
// We want to cancel only the AJAX request, not stop the Epic from listening for any future actions.
// https://github.com/redux-observable/redux-observable/blob/master/docs/recipes/Cancellation.md
return Observable
.ajax
.get(action.payload.url)
.map(data => ({ type: 'FETCH_SUCCEEDED', data }))
.catch(error => Observable.of({ type: 'FETCH_FAILED', error }))
.takeUntil({ type: 'CANCEL_FETCH' });
})
// Or use axios
// import axios from 'axios';
// const fetchEpic = (action$) =>
// action$.ofType('FETCH_REQUESTED')
// .mergeMap(action => {
// // We want to cancel only the AJAX request, not stop the Epic from listening for any future actions.
// // https://github.com/redux-observable/redux-observable/blob/master/docs/recipes/Cancellation.md
// return axios.get(action.payload.url)
// .map(data => ({ type: 'FETCH_SUCCEEDED', data }))
// .catch(error => Observable.of({ type: 'FETCH_FAILED', error }))
// .takeUntil(action$.ofType('CANCEL_FETCH')));
// });
Cancelable (redux-observable)
// Throttling
function* handleInput(input) {
// ...
}
function* watchInput() {
yield throttle(500, 'INPUT_CHANGED', handleInput)
}
// Debouncing
function* handleInput(input) {
// debounce by 500ms
yield call(delay, 500)
...
}
function* watchInput() {
let task
while (true) {
const { input } = yield take('INPUT_CHANGED')
if (task) {
yield cancel(task)
}
task = yield fork(handleInput, input)
}
}
Throttling, Debouncing, Retrying (redux-saga)
// Debouncing written by takeLatest
function* handleInput({ input }) {
// debounce by 500ms
yield call(delay, 500)
...
}
function* watchInput() {
// will cancel current running handleInput task
yield takeLatest('INPUT_CHANGED', handleInput);
}
// Retry 5 times
function* updateApi(data) {
for(let i = 0; i < 5; i++) {
try {
const apiResponse = yield call(apiRequest, { data });
return apiResponse;
} catch(err) {
if(i < 4) {
yield call(delay, 2000);
}
}
}
// attempts failed after 5 attempts
throw new Error('API request failed');
}
// Throttling
const inputEpic = (action$) =>
action$.ofType('INPUT_CHANGED')
.throttleTime(500)
...
// Deboucing
const inputEpic = (action$) =>
action$.ofType('INPUT_CHANGED')
.debounceTime(500)
...
// Retry 5 times without waiting
const inputEpic = (action$) =>
action$.ofType('INPUT_CHANGED')
.retry(5)
...
// Retry 5 times with waiting for 2 seconds
const inputEpic = (action$) =>
action$.ofType('INPUT_CHANGED')
.retryWhen(function(errors) {
return errors
.delay(2000)
.scan((errorCount, err) => {
if(errorCount >= 2) {
throw err;
}
return errorCount + 1;
}, 0);
})
...
Throttling, Debouncing, Retrying (redux-observable)
Wrap it up
redux-observable | redux-sage | |
---|---|---|
學習路徑 | - redux - Functional Programming - RxJS - redux-observable 大約花費數週 |
- redux - ES6 generator - redux-sage 大約花費一週 |
Unit Test | Easy | Easy |
Style | Declarative Style - Pros: 減少 Side effect - Cons: 需要學習FP |
Imperative style - Pros: 人人都習慣的寫作方式 - Cons: 潛在的 Side effect |
redux-observable/redux-saga
By Calvin Huang
redux-observable/redux-saga
- 932