[譯]處理非同步利器 -- Redux-saga

> 本文翻譯自: medium.freecodecamp.com 首發於: 處理非同步利器 -- 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

  1. 確保你引入了所有 effect 和 待測試的方法。

  2. 當你需要使用 yield 存儲一個值到 store 的時候,你需要將模擬數據傳給 next 方法。就如測試 3,4,5。

  3. 然後,在 next 方法被調用後,每個 generator 移動到下一個 yield 操作。這就是為什麼我們要使用 saga.next().value 。

  4. 這一系列測試是確定的。一般來說,如果你改變了 saga 的操作,測試是無法通過的。

總結

我非常樂意嘗試新技術,並且我們會發現,前端開發幾乎每天都會有新東西產生。一旦某個技術被社區接受,就會有很多人想使用它,這對於開發者來說是非常酷的。有時我會從這些新技術中學到很多,但是更重要的是,考慮一下是否我們真的需要它。

我知道 Redux-Thunk 是更容易實現和維護的。但是對於複雜的操作,尤其是面對複雜非同步操作時,Redux-Saga 更有優勢。

最後,感謝 Thomas 的文章給我帶來靈感。我希望大家也能從我的這篇文章中受到啟發。

如果你有任何問題,歡迎[聯繫我]http://twitter.com/andresmijares25),我非常樂意提供幫助。

版權聲明

本譯文僅用於學習、研究和交流目的,歡迎非商業轉載。轉載請註明出處、譯者和眾成翻譯的完整鏈接。要獲取包含以上信息的本文Markdown源文本,請點擊這裡。
推薦閱讀:

用 Three.js, React 和 WebGL 開發遊戲 — SitePoint
前端架構技術選型?
感謝《深入淺出React和Redux》的所有讀者
SegmentFault 技術周刊 Vol.11 - React 應用與實踐
React 事件系統分析與最佳實踐

TAG:Redux | 异步action | React |