[譯]處理非同步利器 -- Redux-saga
> 本文翻譯自: https://medium.freecodecamp.com/async-operations-using-redux-saga-2ba02ae077b3 首發於: 處理非同步利器 -- Redux-saga (眾成翻譯)
幾天前,我和同事談了談如何管理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 {n static getUser() {n return new Promise((resolve) => {n setTimeout(() => {n resolve({n email : "somemockemail@email.com",n repository: "http://github.com/username"n });n }, 3000);n });n }nn static getDeparture(user) {n return new Promise((resolve) => {n setTimeout(() => {n resolve({n userID : user.email,n flightID : 「AR1973」,n date : 「10/27/2016 16:00PM」n });n }, 2500);n });n }nn static getForecast(date) {n return new Promise((resolve) => {n setTimeout(() => {n resolve({n date: date,n forecast: "rain"n });n }, 2000);n });n }n}n
這些API服務是模擬場景中的數據服務。首先,我們需要一個用戶的信息,然後根據這個用戶的信息去獲取航班的起飛信息和天氣預報,從而我們創建了多個數據面板如下:
React 組件代碼可以在這裡找到。這三個組件是不同的,分別對應了三個不同的reducers,如下:
const dashboard = (state = {}, action) => {n switch(action.type) {n case 『FETCH_DASHBOARD_SUCCESS』:n return Object.assign({}, state, action.payload);n default :n return state;n }n};n
由於不同的面板對應不同的reducer,那麼如何獲取用戶信息呢?這裡就用到了redux的方法 mapStateToProps:
const mapStateToProps =(state) => ({n user : state.user,n dashboard : state.dashboardn});n
一切準備就緒(可能我沒有講的很詳細,因為我主要想快速引入sagas),我們開始下一步吧。
Sagas出場
William Deming 曾說過:
如果你無法描述你現在做什麼,那麼你就不算了解你現在做的事情。
Ok,讓我們一步步開始,看看 Redux Saga 是如何工作的。
1. 註冊 Sagas
我會根據我自己的理解描述Redux Saga中的API。如果你想知道更多技術細節,可以參考 Redux-saga 官方文檔。
首先,我們需要創建一個 saga generator,並註冊到Redux中:
function* rootSaga() {n yield[n fork(loadUser),n takeLatest(LOAD_DASHBOARD, loadDashboardSequenced)n ];n}n
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();nconst store = createStore(rootReducer, [], compose(n applyMiddleware(sagaMiddleware)n);nsagaMiddleware.run(rootSaga); /* 將我們的 sagas 插入到這個中間件 */n
3. 創建Sagas
首先,我們會定義 loadUser Saga :
function* loadUser() {n try {n //1st stepn const user = yield call(getUser);n //2nd stepn yield put({type: FETCH_USER_SUCCESS, payload: user});n } catch(error) {n yield put({type: FETCH_FAILED, error});n }n}n
以下是我們的理解:
首先,調用非同步函數 getUser ,然後將返回結果賦值給 user。
然後,觸發 FETCH_USER_SUCCESS Action,並將 user 的值傳給 store 處理。
如果發生異常,則觸發 FETCH_FAILED Action。
正如你所見,我們可以將 yield 操作的結果賦予給一個變數。
接下來,我們創建另一個saga:
function* loadDashboardSequenced() {n try {n yield take(『FETCH_USER_SUCCESS』);nn const user = yield select(state => state.user);nn const departure = yield call(loadDeparture, user);nn const flight = yield call(loadFlight, departure.flightID);nn const forecast = yield call(loadForecast, departure.date);nn yield put({type: 『FETCH_DASHBOARD_SUCCESS』, payload: {forecast, flight, departure} });n } catch(error) {n yield put({type: 『FETCH_FAILED』, error: error.message});n }n}n
以下是我對 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() {n try {n // 等待載入用戶信息n yield take(FETCH_USER_SUCCESS);nn // 從 store 中獲取 用戶信息n const user = yield select(getUserFromState);nn // 獲取航班起飛信息n const departure = yield call(loadDeparture, user);nn // 魔術時刻,見證奇蹟的時候到了n const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];nn // 告訴 store 我們準備好了n yield put({type: FETCH_DASHBOARD2_SUCCESS, payload: {departure, flight, forecast}});n} catch(error) {n yield put({type: FETCH_FAILED, error: error.message});n }n}n
這裡我們 yield 一個數組:
const [flight, forecast] = yield [call(loadFlight, departure.flightID), call(loadForecast, departure.date)];n
因此數組中的這兩個操作是並行執行的,但如果有需要,我們可以等待兩個操作都結束再觸發UI更新。
然後,我們需要在 rootSaga 中註冊 新的 saga :
function* rootSaga() {n yield[n fork(loadUser),n takeLatest(LOAD_DASHBOARD, loadDashboardSequenced),n takeLatest(LOAD_DASHBOARD2 loadDashboardNonSequenced)n ];n}n
一旦操作完成,我們就需要更新UI嗎 ?
我知道你現在還無法回答這個問題,不過別擔心,一會兒我們會給你一個正確答案。
非序列化(Non-Sequenced)且非阻塞(Non-Blocking) Sagas
我們既可以合併這兩個 saga:Flight Saga 和 Forecast Saga,也可以將它們分開,也就是說它們是獨立的。而這點正是我們需要的。下面讓我們看看如何操作:
Step #1:分離 Forecast 和 Flight Saga。它們都依賴航班起飛信息(departure)。
/* **************Flight Saga************** */nfunction* isolatedFlight() {n try {n /* departure 會拿到 put 傳過來的值,也就是 一個完整的 redux action 對象 */n const departure = yield take(FETCH_DEPARTURE3_SUCCESS);nn const flight = yield call(loadFlight, departure.flightID);nn yield put({type: FETCH_DASHBOARD3_SUCCESS, payload: {flight}});n } catch (error) {n yield put({type: FETCH_FAILED, error: error.message});n }n}nn/* **************Forecast Saga************** */nfunction* isolatedForecast() {n try {n /* departure 會拿到 put 傳過來的值,也就是 一個完整的 redux action 對象 */n const departure = yield take(FETCH_DEPARTURE3_SUCCESS);nn const forecast = yield call(loadForecast, departure.date);nn yield put({type: FETCH_DASHBOARD3_SUCCESS, payload: { forecast, }});n} catch(error) {n yield put({type: FETCH_FAILED, error: error.message});n }n}n
這裡有些非常重要的概念,你注意到了嗎?這些概念構成了我們的 sagas :
這兩個 saga 在等待同一個 Action Event (FETCH_DEPARTURE3_SUCCESS)的觸發。
在這個事件(event)被觸發時,它們都會獲得航班起飛信息。更多細節見 Step #2 。
它們都會使用 call Effect 執行自己的非同步操作,並且非同步操作結束後會觸發相同的事件。但是他們發送不同的數據到 store 中處理。多虧了強大的 Redux ,我們這樣做不用改變原來的 reducer。
Step #2:下面,我們稍微修改下 departure saga 以確保它將起飛信息發個另外兩個 saga 。
function* loadDashboardNonSequencedNonBlocking() {n try {n // 等待 FETCH_USER_SUCCESS Actionn yield take(FETCH_USER_SUCCESS);nn // 從 store 中獲取用戶信息n const user = yield select(getUserFromState);nn // 獲取航班起飛信息n const departure = yield call(loadDeparture, user);nn // 發出action,更新 store,並觸發UI更新n yield put({type: FETCH_DASHBOARD3_SUCCESS, payload: { departure, }});nn // 發出 FETCH_DEPARTURE3_SUCCESS Action,觸發 Forecast 和 Flight Sagan // 我們可以在 put 操作中 發生一個對象n yield put({type: FETCH_DEPARTURE3_SUCCESS, departure});n } catch(error) {n yield put({type: FETCH_FAILED, error: error.message});n }n}n
在 put Effect 之前,所有的代碼都和之前一樣。我最喜歡的一點就是,put Effect 很容易將數據作為Action的Payload,發送到 Forecast 和 Flight saga。
你可以看看 demo,看看是第三個panel是如何在載入航班信息前獲取天氣預報的,需要注意的是,獲取航班信息只是模擬了耗時的請求。
在實際應用中,可能我的操作會有些不同,不是模擬請求而是實際請求。這裡我只想說明 putEffect 的價值,你可以很方便的使用 put 傳值。
關於測試
你也做測試吧?
Sagas是非常容易測試的,但是它們需要結合你的步驟,手動使用 generators 一步一步操作。下面,我們看一個例子。(代碼地址)
describe(Sequenced Saga, () => {n const saga = loadDashboardSequenced();n let output = null;nnit(should take fetch users success, () => {n output = saga.next().value;n let expected = take(FETCH_USER_SUCCESS);n expect(output).toEqual(expected);n });nnit(should select the state from store, () => {n output = saga.next().value;n let expected = select(getUserFromState);n expect(output).toEqual(expected);n });nnit(should call LoadDeparture with the user obj, (done) => {n output = saga.next(user).value;n let expected = call(loadDeparture, user);n done();n expect(output).toEqual(expected);n });nnit(should Load the flight with the flightId, (done) => {n let output = saga.next(departure).value;n let expected = call(loadFlight, departure.flightID);n done();n expect(output).toEqual(expected);n });nnit(should load the forecast with the departure date, (done) => {n output = saga.next(flight).value;n let expected = call(loadForecast, departure.date);n done();n expect(output).toEqual(expected);n });nnit(should put Fetch dashboard success, (done) => {n output = saga.next(forecast, departure, flight ).value;n let expected = put({type: FETCH_DASHBOARD_SUCCESS, payload: {forecast, flight, departure}});n const finished = saga.next().done;n done();n expect(finished).toEqual(true);n expect(output).toEqual(expected);n });n});n
確保你引入了所有 effect 和 待測試的方法。
當你需要使用 yield 存儲一個值到 store 的時候,你需要將模擬數據傳給 next 方法。就如測試 3,4,5。
然後,在 next 方法被調用後,每個 generator 移動到下一個 yield 操作。這就是為什麼我們要使用 saga.next().value 。
這一系列測試是確定的。一般來說,如果你改變了 saga 的操作,測試是無法通過的。
總結
我非常樂意嘗試新技術,並且我們會發現,前端開發幾乎每天都會有新東西產生。一旦某個技術被社區接受,就會有很多人想使用它,這對於開發者來說是非常酷的。有時我會從這些新技術中學到很多,但是更重要的是,考慮一下是否我們真的需要它。
我知道 Redux-Thunk 是更容易實現和維護的。但是對於複雜的操作,尤其是面對複雜非同步操作時,Redux-Saga 更有優勢。
最後,感謝 Thomas 的文章給我帶來靈感。我希望大家也能從我的這篇文章中受到啟發。
如果你有任何問題,歡迎[聯繫我]http://twitter.com/andresmijares25),我非常樂意提供幫助。
版權聲明
本譯文僅用於學習、研究和交流目的,歡迎非商業轉載。轉載請註明出處、譯者和眾成翻譯的完整鏈接。要獲取包含以上信息的本文Markdown源文本,請點擊這裡。
推薦閱讀:
※用 Three.js, React 和 WebGL 開發遊戲 — SitePoint
※前端架構技術選型?
※感謝《深入淺出React和Redux》的所有讀者
※SegmentFault 技術周刊 Vol.11 - React 應用與實踐
※React 事件系統分析與最佳實踐