記一次redux-saga的專案實踐總結

程式碼寫著寫著就懂了發表於2018-10-26

前言

本文主要記錄了在專案中使用redux-saga的一些總結,如有錯誤的地方歡迎指正互相學習。

redux中的action僅支援原始物件(plain object),處理有副作用的action,需要使用中介軟體。中介軟體可以在發出action,到reducer函式接受action之間,執行具有副作用的操作。

redux-thunk 和 redux-saga 是 redux 應用中最常用的兩種非同步流處理方式。

之前一直使用redux-thunk處理非同步等副作用操作,在action中處理非同步等副作用操作,此時的action是一個函式,以dispatch,getState作為形參,函式體內的部分可以執行非同步。通過redux-thunk來處理非同步,action可謂是多種多樣,不利於維護。

記一次redux-saga的專案實踐總結

redux-thunk

redux-thunk簡單介紹

redux-thunk 的任務執行方式是從 UI 元件直接觸發任務。

redux-thunk中介軟體可以讓action建立函式先不返回一個action物件,而是返回一個函式,函式傳遞兩個引數(dispatch,getState),在函式體內進行業務邏輯的封裝

redux-thunk 的主要思想是擴充套件 action,使得 action 從一個物件變成一個函式。

redux-thunk使用

比如下面是一個獲取禮品列表的非同步操作所對應的action

export default () => dispatch => {
  fetch('/api/goodList', {
    // fecth返回的是一個promise
    method: 'get', dataType: 'json' }).then(
    json => {
      var json = JSON.parse(json)
      if (json.code === 200) {
        dispatch({ type: 'init', data: json.data })
      }
    }, error => { console.log(error) }
  )
}

複製程式碼

從這個具有副作用的action中,我們可以看出,函式內部極為複雜。如果需要為每一個非同步操作都如此定義一個action,顯然action不易維護。

redux-thunk缺點

總結一下redux-thunk缺點有如下幾點:

  1. action 雖然擴充套件了,但因此變得複雜,後期可維護性降低;

  2. thunks 內部測試邏輯比較困難,需要mock所有的觸發函式;

  3. 協調併發任務比較困難,當自己的 action 呼叫了別人的 action,別人的 action 發生改動,則需要自己主動修改;

  4. 業務邏輯會散佈在不同的地方:啟動的模組,元件以及thunks內部。


redux-saga

redux-saga簡單介紹

redux-saga文件中是這樣介紹的:

redux-saga 是一個用於管理應用程式 Side Effect(副作用,例如非同步獲取資料,訪問瀏覽器快取等)的 library,它的目標是讓副作用管理更容易,執行更高效,測試更簡單,在處理故障時更容易。

剛開始瞭解Saga時,看官方解釋,並不是很清楚到底是什麼?Saga的副作用(side effects)到底是什麼?

通讀了官方文件後,大概瞭解到,副作用就是在action觸發reduser之後執行的一些動作, 這些動作包括但不限於,連線網路,io讀寫,觸發其他action。並且,因為Sage的副作用是通過redux的action觸發的,每一個action,sage都會像reduser一樣接收到。並且通過觸發不同的action, 我們可以控制這些副作用的狀態, 例如,啟動,停止,取消。

所以,我們可以理解為Sage是一個可以用來處理複雜的非同步邏輯的模組,並且由redux的action觸發。

saga特點:

1.saga的應用場景是複雜非同步,如長時事務LLT(long live.transcation)等業務場景。
2.方便測試,可以使用takeEvery列印logger。
3.提供takeLatest/takeEvery/throttle方法,可以便利的實現對事件的僅關注最近事件、關注每一次、事件限頻
4.提供cancel/delay方法,可以便利的取消、延遲非同步請求
5.提供race(effects),[…effects]方法來支援競態和並行場景
6.提供channel機制支援外部事件
複製程式碼

Redux Saga適用於對事件操作有細粒度需求的場景,同時他們也提供了更好的可測試性。

redux-saga使用

注意:⚠️redux-saga是通過ES6中的generator實現的(babel的基礎版本不包含generator語法,因此需要在使用saga的地方import ‘babel-polyfill’)。

redux-saga本質是一個可以自執行的generator。

在 redux-saga 中,UI 元件自身從來不會觸發任務,它們總是會 dispatch 一個 action 來通知在 UI 中哪些地方發生了改變,而不需要對 action 進行修改。redux-saga 將非同步任務進行了集中處理,且方便測試。

所有的東西都必須被封裝在 sagas 中。sagas 包含3個部分,用於聯合執行任務:

worker saga

(1)做所有的工作,如呼叫 API,進行非同步請求,並且獲得返回結果

watcher saga

(2)監聽被 dispatch 的 actions,當接收到 action 或者知道其被觸發時,呼叫 worker saga 執行任務

(3)root saga

立即啟動 sagas 的唯一入口

專案中我是這樣用的,如果你有更好的實現方法請分享給我:

給redux新增中介軟體

在定義生成store的地方,引入並加入redux-sage中介軟體。

// store/index.js

import { createStore, applyMiddleware, compose } from 'redux'
import { routerMiddleware } from 'react-router-redux'
import createSagaMiddleware from 'redux-saga'
import createHistory from 'history/createHashHistory'
import { createLogger } from 'redux-logger'
import { rootSaga } from '../rootSaga'
import reducers from '../reducers/saga-reducer'

const history = createHistory()
const middlewareRouter = routerMiddleware(history)
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
const loggerMiddleware = createLogger({ collapsed: true })
// 這是一個可以幫你執行saga的中介軟體
const sagaMiddleware = createSagaMiddleware()

const store = createStore(reducers,
  composeEnhancers(
  applyMiddleware(
  sagaMiddleware, middlewareRouter, loggerMiddleware
  )))

// 通過中介軟體執行或者說執行saga
sagaMiddleware.run(rootSaga, store)

window.store = store
export default store
複製程式碼

說明:程式啟動時,run(rootSaga) 會開啟 sagaMiddleware 對某些 action 進行監聽,當後續程式中有觸發 dispatch(action) (比如:使用者點選)的時候,由於資料流會經過 sagaMiddleware,所以 sagaMiddleware 能夠判斷當前 action 是否有被監聽?如果有,就會進行相應的操作(比如:傳送一個非同步請求);如果沒有,則什麼都不做。

// rootSaga.js

// 處理瀏覽器相容問題
import 'babel-polyfill'
import { all,call } from 'redux-saga/effects'
import { lotterySagaRoot } from './components'
import { getchampionListFlow, getTabsListFlow } from './container'

export function* rootSaga () {
  yield all([call(getTabsListFlow),
    call(getchampionListFlow),
    call(lotterySagaRoot),
  ])
}

複製程式碼

rootSaga是我們實際傳送給Redux中介軟體的。

rootSaga在應用程式啟動時被觸發一次,可以被認為是在後臺執行的程式,監視著所有的動作派發到倉庫(store)。

我們單拿出一個 getTabsListFlow 這個saga來進行講解究竟發生了什麼?

寫到這裡有必要說一下業務邏輯了,getTabsListFlow這個函式是一個watcher saga,它 watch 的誰呢?getTabsList這個worker saga函式,廢話不多說看程式碼:

// 處理瀏覽器相容問題
import 'babel-polyfill'
import { call, put, take, fork } from 'redux-saga/effects'
import * as types from '../../action_type'
import { lists } from '../../actions/server'

const { GETLIST, TABS_UPDATE, START_FETCH, FETCH_ERROR, FETCH_END } = types

//----worker saga

function* getTabsList (tabs, rule, env) {
  yield put({ type: START_FETCH })
  try {
    return yield call(lists, tabs, rule, env)
  } catch (err) {
    yield put({ type: FETCH_ERROR,err})
  } finally {
    yield put({ type: FETCH_END })
  }
}

//-----watcher saga

export default function* getTabsListFlow() {
  while (true) {
    const { tabs, rule, env } = yield take(GETLIST)
    const { code, data } = yield call(getTabsList, tabs, rule, env)
    yield put({ type: TABS_UPDATE, data, code })
  }
}

複製程式碼

上面的程式碼可以看到,getTabsListFlow這個函式響應一個action,“GETLIST”,獲取tabs, rule, env這三個引數傳給,getTabsList,這個函式,然後把獲取到的結果通過響應一個TABS_UPDATE這個action.type給reducer去出更新資料到頁面。

那麼這些call, put, take, fork這些API後面會講,總之就是讓函式執行獲取資料嘛。我們現在需要知道資料流是怎樣實現的?

問題1:

“GETLIST”這個action.type代表的是哪個函式,這個函式怎麼獲取到tabs, rule, env這三個引數的?看程式碼,其實真的很簡單。。。

// actions/index.js
export function getList(tabs, rule, env) {
  return {
    type: GETLIST,
    tabs,
    rule,
    env,
  }
}

複製程式碼

看到沒有我匯出了這樣一個函式,給了它一個action.type就是叫GETLIST, yield take(GETLIST)就是讓這個函式執行了,這三個引數也是這樣傳遞進來的,我只需要在頁面上引入這個函式去讓個函式執行並傳遞引數就行了。

import React, { Component } from 'react'
import { bindActionCreators } from 'redux'
import { Link } from 'react-router-dom'
import PropTypes from 'prop-types'
import { connect } from 'react-redux'
import { getList } from '../../actions/index'

class List extends Component {
  state = {
    tabs: 'anchor',
    rule: 'hour',
    active: 'anchor',
    hover: 'allanchor',
    visible: false,
  }

  componentDidMount() {
    const { tabs, rule } = this.state
    this.props.getList(tabs, rule, env)
  }
  
  
  
  ....省略一些程式碼
  
  
  
  List.propTypes = {
  getList: PropTypes.func
}
function mapStateToProps(state) {
  return {
    ...state,
  }
}

function mapDispatchToProps(dispatch) {
  return {
    getList: bindActionCreators(getList, dispatch),
  }
}
export default connect(
  mapStateToProps,
  mapDispatchToProps
)(List)
複製程式碼

這樣的話"const { tabs, rule, env } = yield take(GETLIST)"這一段程式碼就獲取到我傳遞的引數了。

這裡設計到了redux的知識,參考:阮一峰Redux 入門教程

問題2:

接下來yield call(getTabsList, tabs, rule, env),讓getTabsList執行,裡面發了一個請求lists執行並傳遞引數。

lists是什麼?其實它就是一個非同步請求。

/**
 * 排行榜
 *
 * @param {String} type
 * @param {String} rule
 * @return {Promise}
 */
export const lists = (type, rule) => req({
  endpoint: `${APP_NAME}/data/${type}/${rule}/${env}`,
  method: GET,
})
複製程式碼

這個是一個被封裝好的fectch請求。類似於這樣

// 通過fetch獲取百度的錯誤提示頁面
fetch('https://www.baidu.com/search/error.html?a=1&b=2', { 

// 在URL中寫上傳遞的引數
    method: 'GET'
  })
  .then((res)=>{
    return res.text()
  })
  .then((res)=>{
    console.log(res)
  })
複製程式碼

接下來執行到這裡 const { code, data } = yield call(getTabsList, tabs, rule, env)

yield put({ type: TABS_UPDATE, data, code }),到這裡我們已經通過請求獲取到我們想要的資料了,下一步就是去reducer裡生成新的state了。


const userReducer = (state = defaultState, action = {}) => {
  const { type} = action;
  switch (type) {
   case TABS_UPDATE:
    return Object.assign({}, state, { list: action.data, loading: false })
    default: return state;
  }
};
複製程式碼

總結一下:

(1)引入的 redux-saga/effects 都是純函式,每個函式構造一個特殊的物件,其中包含著中介軟體需要執行的指令,如:call(lists, tabs, rule, env) 返回一個類似於 {type: CALL, function: lists, args: [tabs, rule, env]} 的物件。

(2)在 watcher saga getTabsListFlow中:

首先 yield take(GETLIST) 來告訴中介軟體我們正在等待一個型別為 GETLIST 的 action,然後中介軟體會暫停執行 getTabsListFlow generator 函式,直到 GETLIST action(getList) 被 dispatch。一旦我們獲得了匹配的 action,中介軟體就會恢復執行 generator 函式。

下一條指令 const { code, data } = yield call(getTabsList, tabs, rule, env) 告訴中介軟體去執行getTabsList,並把{tabs, rule, env} 作為 getTabsList 函式的引數傳遞。中介軟體會觸發 getTabsList generator。

(3)在 worker saga getTabsList 中, yield call(lists, tabs, rule, env)指示中介軟體去呼叫 fetch 函式,同時,會阻塞getTabsList 的執行,中介軟體會停止 generator 函式,直到 fetch 返回的 Promiseresolved(或 rejected),然後才恢復執行 generator 函式。

借一張基於 redux-saga 的一次 完整單向資料流單項資料流的圖:

記一次redux-saga的專案實踐總結

到此為止就是我在專案中使用redux-saga針對於其中一個請求來實現的資料處理。

下面開始介紹一些API的使用了:

redux-saga API

安裝啥的步驟直接略過....

Effects

前面說到,saga 是一個 generator function,這就意味著它的執行原理必然是下面這樣:

function isPromise(value) {
    return value && typeof value.then === 'function';
}

const iterator = saga(/* ...args */);

// 方法一:
// 一步一步,手動執行
let result;

result = iterator.next();
result = iterator.next(result.value);
result = iterator.next(result.value);
// ...
// done!!



// 方法二:
// 函式封裝,自主執行
function next(args) {
  const result = iterator.next(args);
  if (result.done) {
    // 執行結束
    console.log(result.value);
  } else {
    // 根據 yielded 的值,決定什麼時候繼續執行(resume) 
    if (isPromise(result.value)) {
      result.value.then(next);
    } else {
      next(result.value)
    }
  }
}

next();

複製程式碼

也就是說,generator function 在未執行完前(即:result.done === false),它的控制權始終掌握在 執行者(caller)手中,即:

  • caller 決定什麼時候 恢復(resume)執行。

  • caller 決定每次 yield expression 的返回值。

而 caller 本身要實現上面上述功能需要依賴原生 API :iterator.next(value) ,value 就是 yield expression 的返回值。

舉個例子:

function* hello() {
  const value = yield Promise.reslove('hello saga');
  console.log('value: ', value); // value??
}
複製程式碼

單純的看 hello 函式,沒人知道 value 的值會是多少?

這完全取決於 gen 的執行者(caller),如果使用上面的 next 方法來執行它,value 的值就是 'hello saga',因為 next 方法對 expression 為 promise 時,做了特殊處理(這不就是縮小版的 co 麼~ wow~⊙o⊙)。

換句話說,expression 可以是任何值,關鍵是 caller 如何來解釋 expression,並返回合理的值

以此結論,推理來看:

大家熟知的 co 可以認為是一個 caller,它解釋的 expression 是:promise/thunk/generator function/iterator 等。

這裡的 sagaMiddleware 也算是一個 caller,它主要解釋的 expression 就是 effect(當然還可以是 promise/iterator) 。

講了這麼多,那麼 effect 到底是什麼呢?先來看看官方解釋:

An effect is a plain JavaScript Object containing some instructions to be executed by the saga middleware.

意思是說:effect 本質上是一個普通物件,包含著一些指令資訊,這些指令最終會被 saga middleware 解釋並執行。

用一段程式碼​來解釋上述這句話:

function* fetchData() {
  // 1. 建立 effect
  const effect = call(ajax.get, '/userLogin');
  console.log('effect: ', effect);
  // effect:
  // {
  //   CALL: {
  //     context: null,
  //     args: ['/userLogin'],
  //     fn: ajax.get,
  //   }
  // }


  // 2. 執行 effect,即:呼叫 ajax.get('/userLogin')
  const value = yield effect;
  console.log('value: ', value);
}
複製程式碼

可以明顯的看出:

call 方法用來建立 effect 物件,被稱作是 effect factory。

yield 語法將 effect 物件 傳給 sagaMiddleware,被解釋執行,並返回值。

這裡的 call effect 表示執行 ajax.get('user/Login') ,又因為它的返回值是 promise, 為了等待非同步結果返回,fetchData 函式會暫時處於 阻塞 狀態。

除了上述所說的 call effect 之外,redux-saga 還提供了很多其他 effect 型別,它們都是由對應的 effect factory 生成,在 saga 中應用於不同的場景,比較常用的是:

takeEvery

允許多個請求同時執行,不管之前是否還有一個或多個請求尚未結束。

// 首先我們建立一個將執行非同步 action 的任務:
import { call, put,takeEvery } 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});
   }
}

//然後在每次 FETCH_REQUESTED action 被髮起時啟動上面的任務。
function* watchFetchData() {
  yield* takeEvery('FETCH_REQUESTED', fetchData)
}
複製程式碼

在上面的例子中,takeEvery 允許多個 fetchData 例項同時啟動。在某個特定時刻,儘管之前還有一個或多個 fetchData 尚未結束,我們還是可以啟動一個新的 fetchData 任務,

如果我們只想得到最新那個請求的響應(例如,始終顯示最新版本的資料)。我們可以使用 takeLatest 輔助函式。

takeLatest

作用同takeEvery一樣,唯一的區別是它只關注最後,也就是最近一次發起的非同步請求,如果上次請求還未返回,則會被取消。


function* watchFetchData() {
  yield takeLatest('FETCH_REQUESTED', fetchData)
}

複製程式碼

call

all用來呼叫非同步函式,將非同步函式和函式引數作為call函式的引數傳入,返回一個js物件。saga引入他的主要作用是方便測試,同時也能讓我們的程式碼更加規範化。

同js原生的call一樣,call函式也可以指定this物件,只要把this物件當第一個引數傳入call方法就好了

saga同樣提供apply函式,作用同call一樣,引數形式同js原生apply方法。

// 模擬資料非同步獲取
function fn() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('hello saga');
    }, 2000);
  });
}

function* fetchData() {
  // 等待 2 秒後,列印歡迎語(阻塞)
  const greeting = yield call(fn);
  console.log('greeting: ', greeting);
    
}
複製程式碼

fork

非阻塞任務呼叫機制:上面我們介紹過call可以用來發起非同步操作,但是相對於 generator 函式來說,call 操作是阻塞的,只有等 promise 回來後才能繼續執行,而fork是非阻塞的 ,當呼叫 fork 啟動一個任務時,該任務在後臺繼續執行,從而使得我們的執行流能繼續往下執行而不必一定要等待返回。

還是上面的栗子:

// 模擬資料非同步獲取
function fn() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('hello saga');
    }, 2000);
  });
}

function* fetchData() {

  // 立即列印 task 物件(非阻塞)
  const task = yield fork(fn);
  console.log('task: ', task);
}
複製程式碼

顯然,fork 的非同步非阻塞特性更適合於在後臺執行一些不影響主流程的程式碼(比如:後臺打點/開啟監聽),這往往是加快頁面渲染的一種方式。

put

作用和 redux 中的 dispatch 相同。

yield put({ type: 'CLICK_BTN' });

複製程式碼

select

作用和 redux thunk 中的 getState 相同。

const id = yield select(state => state.id);

複製程式碼

take

take(pattern) 用以下規則來解釋 pattern:

1.如果呼叫 take 時引數為空,或者傳入 '*',那將會匹配所有發起的 action(例如,take() 會匹配所有的 action)。

2.如果是一個函式,action 會在 pattern(action) 返回為 true 時被匹配(例如,take(action => action.entities) 會匹配那些 entities 欄位為真的 action)。

3.如果是一個字串,action 會在 action.type === pattern 時被匹配(例如,take(INCREMENT_ASYNC))。

4.如果引數是一個陣列,會針對陣列所有項,匹配與 action.type 相等的 action(例如,take([INCREMENT, DECREMENT]) 會匹配 INCREMENT 或 DECREMENT 型別的 action)。
複製程式碼

當在generator中使用 take語句等待 action 時, generator被阻塞,等待 action被分發,然後繼續往下執行,有種 Event.once() 事件監聽的感覺。

export function* getAdDataFlow() {
    while (true){
        let request = yield take(homeActionTypes.GET_AD);
        let response = yield call(getAdData,request.url);
        yield put({type:homeActionTypes.GET_AD_RESULT_DATA,data:response.data})
    }
}
複製程式碼

take VS tackEvery

takeEvery 只是監聽每個 action ,然後執行處理函式。對於合適響應 action 和如何響應 action, tackEvery 沒有許可權。

最大的區別:

take 只有在執行流達到時才回響應 action ,而 takeEvery 則一經註冊,都會響應action

all

all提供了一種並行執行非同步請求的方式。之前介紹過執行非同步請求的api中,大都是阻塞執行,只有當一個call操作放回後,才能執行下一個call操作,call提供了一種類似Promise中的all操作,可以將多個非同步操作作為引數參入all函式中, 如果有一個call操作失敗或者所有call操作都成功返回,則本次all操作執行完畢。

import { all, call } from 'redux-saga/effects'
 
// correct, effects will get executed in parallel
const [users, repos]  = yield all([
  call(fetch, '/users'),
  call(fetch, '/repos')
])

複製程式碼

race

有時候當我們並行的發起多個非同步操作時,我們並不一定需要等待所有操作完成,而只需要有一個操作完成就可以繼續執行流。這就是race的用處。

他可以並行的啟動多個非同步請求,只要有一個 請求返回(resolved或者reject),race操作接受正常返回的請求,並且將剩餘的請求取消。

import { race, take, put } from 'redux-saga/effects'
 
function* backgroundTask() {
  while (true) { ... }
}
 
function* watchStartBackgroundTask() {
  while (true) {
    yield take('START_BACKGROUND_TASK')
    yield race({
      task: call(backgroundTask),
      cancel: take('CANCEL_TASK')
    })
  }
}
複製程式碼

actionChannel  

在之前的操作中,所有的action分發是順序的,但是對action的響應是由非同步任務來完成,也即是說對action的處理是無序的。

如果需要對action的有序處理的話,可以使用actionChannel函式來建立一個action的快取佇列,但一個action的任務流程處理完成後,才可是執行下一個任務流。

import { take, actionChannel, call, ... } from 'redux-saga/effects'
 
function* watchRequests() {
  // 1- Create a channel for request actions
  const requestChan = yield actionChannel('REQUEST')
  while (true) {
    // 2- take from the channel
    const {payload} = yield take(requestChan)
    // 3- Note that we're using a blocking call
    yield call(handleRequest, payload)
  }
}
 
function* handleRequest(payload) { ... }
複製程式碼

Error Handling

在 saga 中,無論是請求失敗,還是程式碼異常,均可以通過 try catch 來捕獲。

倘若訪問一個介面出現程式碼異常,可能是網路請求問題,也可能是後端資料格式問題,但不管怎樣,給予日誌上報或友好的錯誤提示是不可缺少的,這也往往體現了程式碼的健壯性,一般會這麼做:

function* saga() {
 try {
   const data = yield call(fetch, '/someEndpoint');
   return data;
 }  catch (error) {
    yield put(onError(error));
  }
}
複製程式碼

Watcher/Worker

指的是一種使用兩個單獨的 Saga 來組織控制流的方式。

Watcher: 監聽發起的 action 並在每次接收到 actionfork 一個 worker

Worker: 處理 action 並結束它。


function* watcher() {
  while(true) {
    const action = yield take(ACTION)
    yield fork(worker, action.payload)
  }
}

function* worker(payload) {
  // ... do some stuff
}

複製程式碼

事實上因為專案的侷限性很多API並沒有用上,可以根據專案的實際需求使用這些API,因為它們真的很有意思!!

以上~


參考

文章:

專案參考:

相關文章