關於 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。

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

  • 873