目前主流的redux開發有redux-thunk, redux-saga與redux-observable,到底該使用哪個middleware呢?他們又各自解決了以前哪些開發上的問題呢?就讓我們一起來看看吧!

redux加入非同步的目的

copyright by Akshatha

當我們在討論為什麼需要在redux處理非同步這個議題的時候,我們要先討論為什麼我們需要在redux用非同步
Redux主要解決了以下的問題:

  1. 將邏輯處理抽離出Component
  2. 需要共用部分資料
  3. 方便追蹤除錯問題

然而,其他middleware的出現也想試圖解決其他問題,讓我們繼續看吧!

redux-thunk

redux-thunk是目前最易上手的redux套件,很多初學redux的同學們應該都碰過這個套件。
所以一開始redux的使用方式,我會先以redux-thunk當作範例來示範給大家看。
這邊也提供一個簡易的redux範例提供給大家看:

未使用redux-thunk之前

如果你需要使用套件做非同步的話,需要從外部丟dispatch進去到redux裡面去使用
如此才可以控制什麼時候該執行什麼運算:

1
2
3
useEffect(()=>{
fetchUsers(dispatch);
}, []);

到需要進行非同步地方使用

1
2
3
4
5
6
7
8
const fetchUsers = (dispatch) => {
return fetch(`https://jsonplaceholder.typicode.com/users`)
.then(response => response.json())
.then(
data => dispatch(getUsersSuccess(data)),
error => dispatch(getUserError(error))
);
}

如果應用在需要進行非同步的處理的話,可以這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const Example = ({ dispatch, userId }) => {
useEffect(()=>{
handleAllData(dispatch, userId);
}, []);
}

const loadSomeData = (dispatch, userId) => {
return fetch(`http://data.com/${userId}`)
.then(res => res.json())
.then(
data => dispatch({ type: 'LOAD_SOME_DATA_SUCCESS', data }),
err => dispatch({ type: 'LOAD_SOME_DATA_FAILURE', err })
);
}
const loadOtherData = (dispatch, userId) => {
return fetch(`http://data.com/${userId}`)
.then(res => res.json())
.then(
data => dispatch({ type: 'LOAD_OTHER_DATA_SUCCESS', data }),
err => dispatch({ type: 'LOAD_OTHER_DATA_FAILURE', err })
);
}
const handleAllData = (dispatch, userId) => {
return Promise.all(
loadSomeData(dispatch, userId),
loadOtherData(dispatch, userId)
);
}

使用redux-thunk之後

由於middleware的助攻,你可以直接使用dispatch

1
2
3
useEffect(()=>{
dispatch(fetchUsers());
}, []);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const fetchUsers = () => {
return (dispatch) => {
dispatch(requestUsers());
return fetch("https://jsonplaceholder.typicode.com/users")
.then((response) => response.json())
.then((users) => {
console.log('users: ', users);
dispatch(getUsersSuccess(users));
})
.catch((error) => {
console.log('error: ', error);
dispatch(getUserError(error));
});
};
};

讓我們看看優化之後,差了多少:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const loadSomeData = (userId) => {
return dispatch => fetch(`http://data.com/${userId}`)
.then(res => res.json())
.then(
data => dispatch({ type: 'LOAD_SOME_DATA_SUCCESS', data }),
err => dispatch({ type: 'LOAD_SOME_DATA_FAILURE', err })
);
}
const loadOtherData = (userId) => {
return dispatch => fetch(`http://data.com/${userId}`)
.then(res => res.json())
.then(
data => dispatch({ type: 'LOAD_OTHER_DATA_SUCCESS', data }),
err => dispatch({ type: 'LOAD_OTHER_DATA_FAILURE', err })
);
}
const loadAllData = (userId) => {
return dispatch => Promise.all(
dispatch(loadSomeData(userId)),
dispatch(loadOtherData(userId))
);
}

const Example = ({ dispatch, userId }) => {
useEffect(()=>{
dispatch(loadAllData(userId));
}, []);
}

redux-saga

另外有一套更方便處理非同步的套件叫做redux-saga,讓你更方便地管理整個應用程式的side effects,無論是寫測試或是除錯也方便更多。
那你應該會問,為什麼redux-thunk也可以達成同樣的結果,我們還需要用redux-saga呢?
因為不管是以前的寫法還是redux-thunk,要做到非同步都需要以callback的方式撰寫,如果今天你要進行很多的非同步請求,那你會寫出一套callback hell,因此redux-saga會是改善你程式整潔度的一種解決方案。
可參考官方的敘述:
You might’ve used redux-thunk before to handle your data fetching. Contrary to redux thunk, you don’t end up in callback hell, you can test your asynchronous flows easily and your actions stay pure.

何謂saga

讓我們看官方的敘述:
The mental model is that a saga is like a separate thread in your application that’s solely responsible for side effects. redux-saga is a redux middleware, which means this thread can be started, paused and cancelled from the main application with normal redux actions, it has access to the full redux application state and it can dispatch redux actions as well.

每一個saga都視為是個別獨立的thread,你可以任意操作個別的thread該執行, 暫停或是取消。
就這樣而已,概念非常簡單,如此即可更容易掌控side effects。

如何達成非同步

官方的描述:
It uses an ES6 feature called Generators to make those asynchronous flows easy to read, write and test. By doing so, these asynchronous flows look like your standard synchronous JavaScript code. (kind of like async/await, but generators have a few more awesome features we need)

非同步的方式是使用了ES6的Generators,同時也讓整個非同步的操作變得更易讀寫與測試。

開始了解如何設計Redux-saga吧

這邊提供了一個範例給大家參考,也是拿剛剛的範例改成redux-saga版本:

認識小朋友們

  1. takeLatest(action, saga, …args)
    開始一個新saga,如果有其他saga正在跑會被cancelled。

  2. put(action)
    用法類似之前使用的dispatch,要執行哪個action。

  3. call(fn, …args)
    function可以是一般或是Generator function,如果裡面放的是Promise,他會等待這個Promise被resolve才會完成。

設置redux-saga

  1. store
    在我們的store需要加入sagaMiddleware執行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    import { createStore, applyMiddleware } from 'redux';
    import createSagaMiddleware from 'redux-saga';
    import rootReducer from './reducers';
    import rootSaga from './sagas';
    const sagaMiddleware = createSagaMiddleware();
    const store = createStore(
    rootReducer,
    applyMiddleware(sagaMiddleware);
    )
    sagaMiddleware.run(rootSaga);
  2. root saga
    這裡是saga的index/root,主要是用來集合所有的sagas

    1
    2
    3
    4
    5
    6
    7
    8
    import { all } from 'redux-saga/effects';
    import userSagas from './userSagas';

    export default function* rootSaga() {
    yield all([
    userSagas(),
    ])
    };
  3. 開始撰寫saga
    這裡會用到剛剛我們講到的小朋友們唷:all, put, takeEvery

    1
    2
    3
    import * as types from "../config/types";
    import { call, put, takeEvery } from 'redux-saga/effects';
    ...
  4. 開始一個saga
    開始一個saga,放入對應的action與需要執行的saga。

    1
    2
    3
    function* userSaga() {
    yield takeEvery(types.GET_USERS_REQUEST, fetchUsers);
    }
  5. 執行saga
    這裡會用到call來呼叫API,等到資料回來,才會執行下面的success action。
    如果失敗,則會呼叫error action。
    你會發現這裡就是之前用dispatch所撰寫的部分,換成這樣也不難理解。
    你問actions怎麼寫?寫法跟之前一樣!一樣放在下面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function* fetchUsers(action) {
try {
const users = yield call(getApi, action.payload);
yield put(getUsersSuccess(users));
} catch(error) {
yield put(getUserError(error.message));
}
}

const getApi = () => {
return fetch(apiUrl, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
}
}).then(res => res.json())
.catch(error => {throw error})
}

export const getUsersSuccess = (users) => {
console.log('request users success');
return {
type: types.GET_USERS_SUCCESS,
payload: {
loading: false,
users: users,
}
};
};

export const getUserError = (error) => {
console.log('request users error');
return {
type: types.GET_USERS_FAILED,
error: error
};
};
  1. 補充
    我們的進入點是由saga呼叫fetchUsers來執行action
    1
    2
    3
    4
    5
    6
    7
    8
    9
    export function fetchUsers() {
    console.log('request users');
    return {
    type: types.GET_USERS_REQUEST,
    payload: {
    loading: true
    },
    }
    };

基本上概念都講完了,如果還有不清楚的地方,可以去看看我上面附上的demo程式哦。
也可以參考Redux Saga 真好用,這裡有提供redux-saga lifecycle給大家看。

Redux面臨的問題

儘管Redux解決了很多前端管理資料的問題,也有一些middleware解決了一些問題,但是另一方面也面臨了其他問題:

  1. 非同步的處理方式顯得麻煩
  2. 制式的使用方式顯得囉唆

我們先來回到最初的感動,來談一開始我們會在Redux做哪些事情:

  1. 設置action type config

    1
    2
    3
    const REQUEST_LOAD_USER = 'REQUEST_LOAD_USER';
    const LOAD_USER_SUCCESS = 'LOAD_USER_SUCCESS';
    const LOAD_USER_ERROR = 'LOAD_USER_ERROR';
  2. 定義完action type之後,再來開始定義action function

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    const loadUser = data => ({
    type: REQUEST_LOAD_USER,
    payload: { data },
    })

    const loadUserError = error => ({
    type: LOAD_USER_ERROR,
    payload: { error }
    })

    const fetchUser = (params) => {
    return dispatch => {
    return fetch(params)
    .then(loadUser)
    .catch(loadUserError);
    }
    }
  3. 設置reducer

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    const reducer = (state = initialState, action) => {
    const { type, payload } = action;
    switch(action.type){
    case REQUEST_LOAD_USER: {
    return { ...state, loading: true };
    }
    case LOAD_USER_SUCCESS: {
    return { ...state, loading: false, data: payload.data };
    }
    case LOAD_USER_ERROR: {
    return { ...state, loading: false, data: null, error: payload.error};
    }
    }
    }

寫過的人看完應該會認為 嗯,就是這樣呀?看起來沒太大的問題

但是你仔細觀察會發現一些問題:

  1. 每設置一次就要寫action type, action和reducer,管理不集中
  2. 多人協作reducer的時候很常發生git conflict

如果有另一種middleware可以讓資料處理更方便處理與管理,會不會比較好呢?

redux-observable(Rxjs)

redux-observable是另一套處理非同步的Rxjs based解決方案,如果你已經學過Rxjs,那麼使用redux-observable會非常自然的上手。
你可以在redux上使用Rxjs進行Functional Reactive Programming管理side effects。
Rxjs本身的概念雖然不難,但是在理解原理上是需要很多相關知識才能融會貫通的。
有興趣可以看這部影片Netflix JavaScript Talks-RxJS + Redux + React = Amazing!
裡面所講的概念很簡單,可以拿來當作玩具玩玩看

Epics管理side effects

透過讓所有的actions 串流(stream)dispatch,並且再傳回一個新的串流action進行dispatch。
簡單來說就是:action in, action out.

舉例,我們在寫action的時候可能會:

1
2
3
4
5
const pingPong = (action, store) => {
if(action.type === 'PING') {
return { type: 'PONG' };
}
}

換成Epic的話,會變成這樣:

1
2
3
const pingPongEpic = (action$, store) => 
action$.ofType('PING')
.map(action => ({ type: 'PONG' }));

上面的例子你如果要看成是打PING PONG的話,打的時候中間會見隔一段時間才聽到聲音
所以不會是像上面那樣PING!PONG! 而是PING~PONG~
所以中間你可以加上一些時間做delay

1
2
3
4
const pingPongEpic = (action$, store) => 
action$.ofType('PING')
.delay(2000)
.map(action => ({ type: 'PONG' }));

實際上你會發現開發上你很好進行擴充功能,因為每次使用function都會return自己,因此就可以不斷的使用,製造出一串的function chain。

什麼是Reactive Programming

Reactive表示發生事情會即時反應,它有一點像監聽的概念。
可以拿下面的範例類比,被點擊會馬上log出來。

1
window.addEventListener('click', () => console.log('click'));

何謂Observable與Observer

基於Reactive Programming的開發,只要Observable被trigger,Observer就會作出反應。
這邊寫了一個跟上面相似的範例,把一個Event相當於是一個Observable,也就是說我subscribe了這個Observable,我會執行我所subscribe的function。

1
2
Rx.Observable.fromEvent(window, 'click')
.subscribe(() => console.log('click'));

所以…到底什麼是Observable?
Observable英文意思是:可被觀察的對象
Observable可以是任何東西,當有新事件被trigger的時候,就會即時做出反應。
我認為Observable這個名詞真的取得蠻厲害的,因為使用後真的是Observable,做什麼事情都一覽無遺了。

所以…到底跟addEventListener有什麼兩樣?
它特別的地方在於可以把資料轉換成你所期望的model進行接下來的動作

1
2
3
Rx.Observable.fromEvent(window, 'click')
.map(e => e.target)
.subscribe(value => console.log(value))

運用在Redux上

  1. 設置observer type
    你可以想像成是action type

    1
    2
    3
    4
    5
    const observer = {
    next: (n) => console.log("value: ", n),
    error: (error) => console.log("error: ", error),
    complete: () => console.log("finish")
    };
  2. 撰寫Observable
    你可以在observable內撰寫你所要進行的任務,同時也可以撰寫如果unsubscribe需要執行什麼動作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    const observable = new Observable((observer) => {
    let total = 1;
    const handle = setInterval(() => {
    observer.next(total++);
    if (total > 10) {
    clearInterval(handle);
    observer.complete();
    }
    }, 1000);
    return {
    unsubscribe: () => console.log("unsubscribe")
    };
    });
  3. subscribe
    subscribe指定的observable,如果在指定的時間內沒完成任務就會unsubscribe

    1
    2
    3
    4
    const subscription = observable.subscribe(observer);
    setTimeout(() => {
    subscription.unsubscribe();
    }, 6200);

當然實際使用在Redux上面有很多方式管理與使用,隨著專案的大小也不略有不同

為什麼我們需要用redux-observable

之前我們提到了redux-thunk與redux-saga都解決了哪些redux面臨的問題
再來,為什麼要用redux-observable?
目前redux的使用量還是以redux-thunk與redux-saga為主流,不過下面還是整理一些可以考慮的一些點思考:

  1. 傳統的Promise無法取消
    為什麼需要取消?當你在SPA切換Route的時候,事實上任務還是繼續做,並沒有停止
  2. 讓程式邏輯更集中管理
  3. Functional Reactive Programming取代callback hell

我認為redux-observable提出的解決方案是很棒的,但是要說服專案切換到這項技術,我認為沒有那麼大的必要需要這樣做
主要是考量的因素過多,且基於專案上誘因並不大,因此基於ES6原生開發的redux-saga反而會是更好的一種考量。

結論

每一項技術的出現,都是有他想要解決的地方,當你無法理解為什麼這項技術會出現或是被大家使用的時候,不如思考一下之前的技術上曾經面臨過什麼問題,而這些技術提供了哪些解決方案改善了哪些問題。
技術的使用,更怕的也是盲目的追求使用新技術,千萬不要因為某些技術很多人使用或是那些技術看起來很炫就隨意地拿來使用,有時候透過了解問題反而更能知道其中的重點是什麼。

參考資料

Redux Saga 真好用