深入淺出Redux Saga——原理淺析

Redux Saga

前言

使用Saga也有一段時間了,剛開始ReduxThunk轉換到Saga的適應期中還是比較難受的,有很多思維上和邏輯上的轉換;后來使用Saga的頻率越來越高,為了更好地使用它就必須去了解它的實現原理和源碼,在慢慢地犯錯和修正的過程中有了一些自己的理解和更好的一些實現方案,在這里記錄下來。

這篇文章主要是讓一些對Saga持有困惑的初學者更好地了解和使用Saga,文章中提到的東西也比較雜,主要都是為了更好地理解Saga。

Side Effects 副作用

我們經常會提到副作用,就是為了處理副作用我們才會使用Thunk, Saga這些工具,那什么是副作用?

什么是副作用?

魯迅說:副作用是在計算結果的過程中,系統狀態的一種變化,或者與外部世界進行的可觀察的交互。

簡單地說,只要是跟函數外部環境發生的交互就都屬于副作用

是不是還是有點困惑?我們舉些例子吧,副作用包括但不限于以下情況:

  • 發送一個 http 請求
  • 更改文件系統
  • 往數據庫插入記錄
  • 使用LocalStorage進行本地存儲
  • 打印/log
  • 獲取用戶輸入
  • DOM 查詢
  • 訪問系統狀態

大概知道什么是副作用之后,我們繼續了解兩個詞:純函數 & 非純函數

什么是純函數?

純函數:

函數與外界交互唯一渠道就是——參數返回值。也就是說:
函數從函數外部接受的所有輸入信息都通過參數傳遞到該函數內部;函數輸出到函數外部的所有信息都通過返回值傳遞到該函數外部。

什么是非純函數?

非純函數:

函數通過參數和返回值以外的渠道,和外界進行數據交換。
比如,讀取/修改全局變量;比如,從local storage讀取數據,還將其打印到屏幕;再比如在函數里發起一個http請求獲取數據。。

那為什么我們要追求純函數?那它當然有它的好處了。

  • 引用透明性:純函數總是能夠根據相同的輸入返回相同的輸出,所以它們就能夠保證總是返回同一個結果。
  • 可移植性/自文檔化:純函數內部與環境無關,可以自給自足,更易于觀察和理解,一切依賴都從參數中傳遞進來,所以僅從函數簽名我們就得知足夠的信息。
  • 可緩存性:由于以上特性,純函數總能夠根據輸入做緩存,例如memoize函數,用一個對象來緩存計算結果。
  • 可測試性:不需要偽造環境,只需簡單地給函數一個輸入,然后斷言輸出就好了。

讓人討厭的副作用?

說了這么多純函數的好處,我們費勁心思將讓人討厭的副作用處理掉,那為什么還要寫有副作用的代碼啊?

但是回過頭想想,副作用都是我們程序里的關鍵,假如沒有副作用,一切都變得毫無意義了。

假如一個前端項目不能發起http請求從后端獲取信息,

假如一個文件系統或者數據庫不能讓我們讀寫數據,

假如不能根據用戶的輸入從屏幕上輸出他們想要的信息

這一切都沒有意義了。

所以我們的任務不是消除副作用,而是要把副作用統一管理,避免一些不該出現的/我們并不希望出現的問題,讓程序看起來更可控更純潔~

所以我們才會在項目的狀態管理中使用thunk,saga等手段處理副作用:)

Why Not Redux Thunk

Redux Thunk也是處理副作用的一個中間件,那為什么不推薦使用Redux Thunk呢?

魯迅說: 因為丑!它不好看!

redux的作者提供了Redux Thunk中間件給我們集中地處理副作用,所以它的優點就是:可以處理副作用。

但是也就僅提供處理副作用這個功能,處理方式相當粗暴簡陋(你看看thunk一共就10行不到的代碼你就懂了),我說說缺點:

  • 內部代碼重復且無意義,邏輯復雜(丑)
  • Action本應是一個純碎的JS對象,但是使用Thunk之后Action的形式千奇百態(丑)
  • 代碼難以測試(因為丑)

舉個丑例子

const GET_DATA = 'GET_DATA'
const GET_DATA_SUCCESS = 'GET_DATA_SUCCESS'
const GET_DATA_FAILED = 'GET_DATA_FAILED'

const getDataAction = id => (dispatch, getState) => {
  dispatch({
    type: GET_DATA,
    payload: id
  })
  api.getData(id)
    .then(res => {
      dispatch({
        type: GET_DATA_SUCCESS,
        payload: res
      })
    })
    .catch(err => {
      dispatch({
        type: GET_DATA_FAILED,
        payload: err
      })
    })
}

綜上,這不是我們高級而優雅的前端工程師想要的結果!!

Why Redux Saga

那為什么就推薦Saga了呢?

魯迅說: 優雅!高級!一眼看不懂!

我們看一下官方介紹吧。

redux-saga is a library that aims to make application side effects easier to manage, more efficient to execute, easy to test, and better at handling failures.

從介紹中可以看到Saga有這么幾個特點:

  • 更容易管理副作用
  • 程序更高效執行
  • 易于測試
  • 易于處理錯誤

那魯迅為什么說人家優雅高級啊,高在哪兒啊?

Redux Saga之所以更受我們歡迎,因為它的核心就是巧妙地使用了ES6的特性——Generator,基于Generator實現異步流程的控制管理。

ES6 Generator

為了更好地理解Saga的原理,了解Generator的基礎知識是必經之路。

(假如你已經很了解Generator的話可以直接跳過本小節)

來,這是一個簡單的Generator函數的例子

function * generator () {
  yield 'hello'
  yield 'world'
}

let gen = generator()

gen.next() // { value: 'hello', done: false}
gen.next() // { value: 'world', done: false}
gen.next() // { value: undefined, done: true}

generator是生成器函數,*:是它的專有標志。

yield是暫停標志,每次程序運行到yield時都會暫停,等待下一次指令的執行;它只能在generator函數里,后面跟著一個表達式。

return是終止標志。

gen是由generator生成器函數生成的一個遍歷器對象。

gen對象擁有next()方法,調用next方法會得到結構為一個內含value和done屬性的對象,value是yield后面表達式的值,done是遍歷是否結束的標志位。

只有執行了next才會開始調用generator函數。next傳入的參數會當作上一個yield表達式的返回值,所以第一次調用next傳入的參數是無效的。

我們通過一個復雜一點點的例子來了解Generator函數

function * generator (x, y) {
  // yield
  // 暫停標志
  // 只能在generator里
  // 后面接著一個表達式
  let a = yield x + y
  // ??a拿到的是next傳來的參數,而不是yield后面的表達式!
  // ??因此我們可以通過next函數在外部改變generator內部的行為
  let b = yield x * y
  // return
  // 終止標志
  return a + b
}

// gen
// 遍歷器對象
let gen = generator(1, 2)

gen.next()
// {value: 3, done: false}
gen.next(9)
// {value: 2, done: false}
gen.next(8)
// {value: 17, done: false}
// 只有執行next才會調用generator
// next傳入的參數會當作上一個yield表達式的返回值
// 所以第一次調用next傳入的參數是無效的

看懂這段代碼最關鍵的點就是

yield前面的變量拿到的是next傳來的參數,而不是yield后面的表達式!

generator.png

(具體就不解釋這段代碼啦,有問題可以留言哈)

這為我待會兒要講Saga核心第一個點作了一個小鋪墊:

Generator通過yield和next來傳遞數據來控制函數的內部流程

Redux Saga

前面鋪墊了這么多,終于要開始講一下Saga了。

Saga是一個中間件,所以我們首先當然要去注冊一下它啦~

import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import reducer from './reducers'
import mySaga from './sagas'

// create the saga middleware
const sagaMiddleware = createSagaMiddleware()
// mount it on the Store
const store = createStore(
  reducer,
  applyMiddleware(sagaMiddleware)
)

// then run the saga
sagaMiddleware.run(mySaga)

export default store

注冊的步驟很簡單,調用createSagaMiddleware,用于創建saga中間件;講saga中間件注入store中,并執行run操作,運行我們寫的saga函數。

再來一個簡單的使用例子:

import { createActions } from 'redux-actions'
import { call, put, takeLatest } from 'redux-saga/effects'
import { fetchDataApi } from '@api/index'

export const {
  data: { fetchDataReq, fetchDataSucc, fetchDataFailed }
} = createActions({
  DATA: {
    FETCH_DATA_REQ: null,
    FETCH_DATA_SUCC: rsp => ({ data: rsp }),
    FETCH_DATA_FAILED: null,
  }
})

function* fetchDataReqSaga() {
  try {
    const rsp = yield call(fetchDataApi)
    yield put(fetchDataSucc(rsp))
  } catch (e) {
    yield put(fetchDataFailed(e))
  }
}

function* watchFetchSaga() {
  yield takeLatest(fetchDataReq, fetchDataReqSaga)
}

export default watchFetchSaga

這是剛剛Redux Thunk同樣的小例子,發起一個action觸發一個http請求,請求成功時發起success action,請求失敗時發起failed action。

在Saga中會使用一種叫做Effect的指令來完成這些操作。如例子中
call,put,takeLatest等等,都是Effect指令。

通俗地講(為了便于大家理解,具體意思下面會再做介紹),call作用是調用其參數中的函數,pull作用是發起一個action,takelatest作用是監聽某個action的觸發并執行回調函數。

Effects

Effects就是簡單的JavaScript對象,我們可以把它視作是發送給saga middleware的一些指令,它僅僅是負責向middleware描述調用行為的信息,而接下來的操作是由middleware來執行,middleware執行完畢后將指令的結果回饋給 Generator。

也就是說我們只需要通過聲明Effects的形式,將副作用的部分都留給middleware來執行。

這樣做的好處:
1: 集中處理異步操作,更流利熟悉地表達復雜的控制流。
2: 保證action是個純粹的JavaScript對象,風格保持統一。
3: 聲明式指令,無需在generator中立即執行,只需通知middleware讓其執行;借助generator的next方法,向外部暴露每一個步驟。

接下來看一下一些常用的effect指令

  • put 用來命令 middleware 向 Store 發起一個 action。
  • take 用來命令 middleware 在 Store 上等待指定的 action。在發起與 pattern 匹配的 action 之前,Generator 將暫停。
  • call 用來命令 middleware 以參數 args 調用函數 fn。
  • fork 用來命令 middleware 以 非阻塞調用 的形式執行 fn。
  • race 用來命令 middleware 在多個 Effect 間運行,相當于Promise.race。
  • all 用來命令 middleware 并行地運行多個 Effect,并等待它* 們全部完成,相當于Promise.all。

Saga輔助函數

Saga除了提供Effects,還會提供一些高階的輔助函數給我們使用,而這些輔助函數實際上也是基于各個Effects實現的。

  • takeEvery(take+fork)
  • takeLatest (take+fork+cancel)
  • takeLeading(take+call)
  • throttle(take+fork+delay)
  • debounce(take+fork+delay)
  • retry (call+delay)

舉個簡單的例子,當用戶點擊按鈕觸發事件時:

  • takeEvery會并發執行(take+fork);
  • takeLastest只執行最后一次(take+fork+cancel);
  • takeLeading只執行第一次(take+call);
  • throttle節流,觸發后一段時間不會再次觸發(take+fork+delay);
  • debounce防抖,等到一段時間后再觸發;
  • retry,多次重試觸發;

Saga原理之Channel

了解完Saga的基本使用,我們開始進一步了解它的原理。先說說Saga中間件里頭有一個Channel的東西,它可以理解為一個action監聽的池子,每次調用take指令的時候就會將對應action的監聽函數放進池子里,當每次調用put指令或者外部發起一個action的時候,Saga就會在池子里匹配對應的監聽函數并執行,然后將其銷毀。

channel

以下是簡化過的Saga源碼channel部分:

function channel () {
  let taker
  function take (cb) {
    taker = cb
  }
  function put (input) {
    if (taker) {
      const tempTaker = taker
      taker = null
      tempTaker(input)
    }
  }
  return {
    put,
    take
  }
}

const chan = channel()

Saga原理之自驅動模式

可能你會有疑惑,為什么Saga中間件會自動按照程序設定的一般地接受指定的Effect指令去執行對應的同步/異步操作呢?

其實這就是Saga基于Generator的一種自驅動模式(這是我自己起的名字)

回過頭想想上文提及Generator時歸結出的一個結論:

Generator通過yield和next來傳遞數據來控制函數的內部流程

Saga就是利用了這一點,而且不止這樣,Saga中間件內部有一個驅動函數(effectRunner),它里面生成一個遍歷器對象來不斷地消費生成器函數(effectProducer)中的effect指令,完成指定的任務并遞歸循環下去。(這時候你可能會想到TJ大神的CO庫)

這個驅動函數大概長這樣

function task (saga) {
  // 初始化遍歷器對象
  const sa = saga()
  function next (args) {
      // 獲取到yield后面的表達式——effect指令
      const result = sa.next(args)
      const effect = result.value
      // 執行effect對應的操作——call/put/take...
      runEffect(result.value, next)
  }
  // 執行next函數
  next()
}

這樣就實現了一個自我驅動的方案,回想一下Saga中間件注冊的步驟

調用createSagaMiddleware,用于創建saga中間件;講saga中間件注入store中,并執行run操作,運行我們寫的saga函數。

當執行run(saga)的時候其實就是啟動了驅動函數(effectRunner),它開始控制我們自己編寫的業務流程Saga函數(effectProducer),effectRunner通過next來控制流程和傳遞數據(執行結果),effectProducer通過yield來發布effect指令,這樣就完美演繹了saga整個生命周期!!

saga.png

Saga源碼

實際上Saga源碼中也就這么一回事,我主要挑了channel和自驅動函數兩塊東西來分享,在Saga源碼中有一個proc函數,其實就是上文提到的自驅動函數,它接收到effect指令之后進行effect類型分發,不同effect對應不同的操作。

saga.png

具體的源碼解析就不在這里細說啦,大家可以自行查閱~(還不是因為怕說錯被噴)

Saga測試

Saga還有一個優點記得嗎?易于測試,由于業務代碼都是聲明式地調用,而不是真實地進行一些副作用操作,所以在寫單測的時候可以通過斷言的方式來測試,而不用mock一些繁瑣復雜的操作。

最后

其實在使用Saga的過程中會有很多的疑惑,例如怎么才是更好的實現方式,怎么才可以更好的實現封裝,減少冗余代碼,本來想在這里和大家繼續分享一些案例,但是實在是學識尚淺,也不確定什么才是s所謂的更好,所以還是鼓勵大家一起去探索啦~

謝謝閱讀~假如對你有幫助的話可以給我點個??~假如有任何疑惑或者描述錯誤的地方可以隨意在評論區留言~感恩~

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373

推薦閱讀更多精彩內容