redux-saga 瞭解一下

sameen發表於2019-04-11

最近專案用了dva,dva對於非同步action的處理是用了redux-saga,故簡單學習了下redux-saga; 以下從 是什麼 為什麼 怎麼用 三方面來了解。

是什麼

redux-saga 就是 redux 的一個中介軟體,用於更優雅地管理副作用(side effects);

redux-saga可以理解為一個 和 系統互動的 常駐程式,可簡單定義: saga = Worker + Warcher

名詞解釋

  • side effects

Side effects are the most common way that a program interacts with the outside world (people, filesystems, other computers on networks) [from Wikipedia]

副作用是 程式與外部世界(人、檔案系統,網路上的其他計算機) 互動的最常用的方式。 對映到前端, 副作用一般指非同步網路請求。

  • Effect

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

effect 是一個普通的 javascript物件,包含一些指令,這些指令最終會被 redux-saga 中介軟體 解釋並執行。

在 redux-saga 世界裡,所有的 Effect 都必須被 yield 才會執行

原則上來說,所有的 yield 後面也只能跟Effect,以保證程式碼的易測性。 eg:

yield fetch(UrlMap.fetchData);

應該用 call Effect : yield call(fetch, UrlMap.fetchData)

  • task task 是 generator 方法的執行環境,所有saga的generator方法都跑在task裡。

為什麼

作用

用於更優雅地管理副作用, 在前端就是非同步網路請求;本質就是為了解決非同步action的問題;

優點
  • 副作用轉移到單獨的saga.js中,不再摻雜在action.js中,保持 action 的簡單純粹,又使得非同步操作集中可以被集中處理。對比redux-thunk

  • redux-saga 提供了豐富的 Effects,以及 sagas 的機制(所有的 saga 都可以被中斷),在處理複雜的非同步問題上更順手。提供了更加細膩的控制流

  • 對比thunk,dispatch 的引數依然是一個純粹的 action (FSA)

  • 每一個 saga 都是 一個 generator function,程式碼可以採用 同步書寫 的方式 去處理 非同步邏輯(No Callback Hell),程式碼變得更易讀。

  • 同樣是受益於 generator function 的 saga 實現,程式碼異常/請求失敗 都可以直接通過 try/catch 語法直接捕獲處理。

怎麼用

hello saga

  1. 單獨的檔案:sagas.js, 統一管理副作用:
export function* helloSaga() {
   console.log('Hello Sagas!');
}
複製程式碼
  1. 將saga和store關聯起來, 入口檔案 main.js:
 import { createStore, applyMiddleware } from 'redux';
 import createSagaMiddleware from 'redux-saga';

 import helloSaga from './sagas'
 import rootReducer from './reducers'

 // 建立 saga middleware
  const sagaMiddleware  = createSagaMiddleware();

 // 建立 store
 const store = createStore(
   rootReducer,
   applyMiddleware(sagaMiddleware)   // 注入 saga middleware
 );

 // 啟動 saga
 sagaMiddleware.run(helloSaga);

 // 省略ReactDOM.render部分的程式碼...
複製程式碼

這時候就可以看到Hello Sagas!了;

程式碼分析:

line 8 :通過redux-saga提供的工廠函式 createSagaMiddleware 建立 sagaMiddleware(當然建立時,你也可以傳遞一些可選的配置引數)。

line 11-14 : 建立 store 例項, 並注入 saga中介軟體。意味著:之後每次執行 store.dispatch(action),資料流都會經過 sagaMiddleware 這一道工序,進行必要的 “加工處理”(比如:傳送一個非同步請求)。

line 17 : 啟動 saga,呼叫run方法使得generator可以開始執行,也就是執行 rootSaga。通常是程式的一些初始化操作(比如:初始化資料、註冊 action 監聽)。

3、接下來加入非同步呼叫的流程

先看下要實現的效果:

計數器

省略UI程式碼;

reducer中已有加一的處理:

...
case 'INCREMENT':
  return {
    ...state,
    count: state.count + 1
}
...
複製程式碼

sagas.js:

 import { all, put, takeEvery } from 'redux-saga/effects'
 const delay = (ms) => new Promise(res => setTimeout(res, ms))

 // worker Saga: 執行非同步的 increment 任務
 export function* incrementAsync() {
   yield delay(1000) // middleware 拿到一個 yield 後的 Promise,暫停1s後再繼續執行
   yield put({ type: 'INCREMENT' })  // 告訴 middleware 發起一個 INCREMENT 的 action。
 }

 // watcher Saga: 在每個INCREMENT_ASYNC上生成一個新的incrementAsync任務
 export function* watchIncrementAsync() {
  yield takeEvery('INCREMENT_ASYNC', incrementAsync)
 }

// 啟動saga們
 export default function* rootSaga() {
  yield all([
    watchIncrementAsync(),
    helloSaga()
  ])
 }
複製程式碼

把saga和store聯絡起來的程式碼和上面相似,就是把helloSaga替換成rootSaga即可;

程式碼分析:

Sagas 是被實現為 Generator functions 的 line 2 : 建立一個delay函式,返回一個Promise,它在指定的毫秒數後解析。 line 5-8 : incrementAsync 這個 Saga 會暫停,直到 delay 返回的 Promise 被 resolve,即 1000ms 之後; line 6 : middleware 拿到一個 yield 後的 Promise,middleware 暫停 Saga,直到 Promise 完成。一旦 Promise 被 resolve,middleware 會恢復 Saga 接著執行,直到遇到下一個 yield。 line 7 : 這裡就是第二個yield啦,這裡的 put({type: 'INCREMENT'}) 就是一個Effect,Effect 是純js物件,其中包含了給 middleware 執行的指令;當 middleware 拿到被Saga yield的Effect的時候,也會暫停Saga,直到Effect 執行完成,然後Saga 會再次被恢復。 line 11-13 : 寫一個watcher saga,用redux-saga的api takeEvery 來監聽所有的 INCREMENT_ASYNC action,並在 action 被匹配時執行 incrementAsync 任務。 line 15-18 : 有了Saga,,現新增一個rootSaga來負責啟動所有Saga,用了all api,如果有其他Saga都能一起啟動。

line 7 返回的是一個Effect,console('Effect', put({ type: 'INCREMENT' }))

Effect

基於redux的資料流: 狀態決定展現,互動就是改狀態

redux的資料流

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

完整單向資料流

api

在第一次使用dva的時候,用的最多的api就是putcall,有時還有用select

❀Effect 建立器(creators)

1、put(action)

建立一個Effect描述資訊,指示 middleware 向Store dispatch一個action

相當於在 saga 中呼叫 store.dispatch(action)。

2、select(selector, ...args)

建立一個Effect,指示 middleware 呼叫提供的選擇器獲取 Store state 上的資料,即獲取狀態

3、call(fn, ...args)

建立一個Effect描述資訊,指示 middleware 以args為引數呼叫fn;

即執行fn(...args); 如果fn是個Generator,或者返回Promise,那麼會阻塞當前 saga 的執行,直到被呼叫函式 fn 返回結果,才會執行下一步程式碼。

4、take(pattern)

建立一個Effect描述資訊,指示 middleware 等待 Store 上指定的 action。 Generator 會暫停(被阻塞了),直到一個與 pattern 匹配的 action 被髮起。 有種事件監聽的感覺。 take的返回值是action

如果呼叫take而沒有引數或是'*',則所有排程的操作都匹配(例如,take()將匹配所有操作)

可以監聽多個,eg: yield take(['LOGOUT', 'LOGIN_ERROR'])

5、fork(fn, ...args)

建立一個Effect描述資訊,指示 middleware 以 無阻塞呼叫 方式執行 fn fork的返回值是task

類似於 call effect,區別在於它不會阻塞當前 saga,如同後臺執行一般,會立即返回一個 task 物件。 yield fork(fn ...args) 的結果是一個 Task 物件 —— 具有一些有用方法和屬性的物件。

6、cancel(task)

建立一個Effect描述資訊,針對 fork 方法返回的 task ,可以進行取消關閉。

7、cancelled()

建立一個Effect描述資訊,指示 middleware 返回 該 generator 是否已經被取消。通常你會在 finally 區塊中使用這個 Effect 來執行取消時專用的程式碼。

❀在強大的低階 API 之上構建的 wrapper effect

8、takeEvery(pattern, saga, ...args)

被 dispatch 的 action 中,在匹配到 pattern 的每一個 action 上派生一個 saga takeEvery 是一個使用 take 和 fork 構建的高階 API。

實現:

const takeEvery = (patternOrChannel, saga, ...args) => fork(function*() {
  while (true) {
    const action = yield take(patternOrChannel)
    yield fork(saga, ...args.concat(action))
  }
})
複製程式碼

9、takeLastest(pattern, saga, ...args)

被 dispatch 的 action 中,在匹配 pattern 的每一個 action 上派生一個 saga。並自動取消之前所有已經啟動但仍在執行中的 saga 任務。 takeLatest 也是一個使用 take 和 fork 構建的高階 API。 實現:

const takeLatest = (patternOrChannel, saga, ...args) => fork(function*() {
  let lastTask
  while (true) {
    const action = yield take(patternOrChannel)
    if (lastTask) {
      yield cancel(lastTask) // 如果任務已經結束,cancel 則是空操作
    }
    lastTask = yield fork(saga, ...args.concat(action))
  }
})
複製程式碼

❀Effect 組合器(combinators)

10、race([...effects])

建立一個Effect描述資訊,指示 middleware 在多個 Effect 之間執行一個 race(與 Promise.race([...]) 的行為類似)。

race可以取到最快完成的那個結果,常用於請求超時

11、all([...effects])

建立一個 Effect 描述資訊,指示 middleware 並行執行多個 Effect,並等待它們全部完成。這是與標準的Promise#all相對應的 API。

也可用[...effects],yield 一個包含 effects 的陣列,eg:

import { call } from 'redux-saga/effects'

// 正確寫法, effects 將會同步執行
const [users, repos] = yield [
  call(fetch, '/users'),
  call(fetch, '/repos')
];
複製程式碼

generator 會被阻塞直到所有的 effects 都執行完畢,或者當一個 effect 被拒絕 (就像 Promise.all 的行為)。

欲瞭解其他api可以訪問: 速查直達

返回

在dva中使用

dva中使用

對於try catch的額外補充

Call vs Fork

saga 中 call 和 fork 都是用來執行指定函式 fn,區別在於:

  • call Effect 會阻塞當前 saga 的執行,直到被呼叫函式 fn 返回結果才執行下一步程式碼。
  • fork Effect 則不會阻塞當前 saga,會立即返回一個 task 物件。

fork 的非同步非阻塞特性更適合於在後臺執行一些不影響主流程的程式碼

高階概念

1、監聽未來的action —— take

先看下要實現的效果:

日誌記錄器

take的實現:

 import { select, take } from 'redux-saga/effects'

 function* watchAndLog() {
  while (true) {
    const action = yield take('*');
    const state = yield select();
    console.log('action', action);
    console.log('state after', state);
  }
 }
複製程式碼

程式碼分析:

這是一個簡單的列印日誌功能 line 5: 指示 middleware 等待一個特定的 action。這裡整個Generator被暫停了,直到匹配到的action被dispatch了,這裡是*,所以是任意一個action; yield take('*')的返回值就是匹配到的action line 6: 用select api 拿到所有狀態 line 4-9: 這裡用了while(true),因為 Generator 函式不具備 從執行至完成 的行為(run-to-completion behavior),這個Generator 會每次迭代到第5行時阻塞,以等待 action 發起。

對比takeEvery,實現一樣的效果:

import { select, takeEvery } from 'redux-saga/effects'

function* watchAndLog() {
  yield takeEvery('*', function* logger(action) { // 這裡action被被動注入回撥了
    const state = yield select()

    console.log('action', action)
    console.log('state after', state)
  })
}
複製程式碼

可以看出,takeEvery 的實現中, 匹配到action就執行回撥, action就被動的被 push 到任務處理函式的。 每次 action 被匹配時任務處理函式就會一遍又一遍地被呼叫。並且它們也無法控制何時停止監聽。 而 take 的實現中,Saga 是自己主動 pull action 的,就像是在執行一個普通函式一樣: action = getNextAction()。主動拿到action就可以控制停止,流程上更靈活;

eg: 監聽使用者的操作,並在使用者初次建立完三條 Todo 資訊時顯示祝賀資訊

import { take, put } from 'redux-saga/effects'

function* watchFirstThreeTodosCreation() {
  for (let i = 0; i < 3; i++) {
    const action = yield take('TODO_CREATED')
  }
  yield put({type: 'SHOW_CONGRATULATION'})
}
複製程式碼

action被匹配到3次之後,Generator 會被回收並且相應的監聽不會再發生

主動拉取 action 可以讓我們使用熟悉的同步風格來描述我們的控制流 eg: 監聽得來,還有順序

function* loginFlow() {
  while (true) {
    yield take('LOGIN')
    // ... perform the login logic
    yield take('LOGOUT')
    // ... perform the logout logic
  }
}
複製程式碼

返回

2、無阻塞呼叫 —— fork

登入流程案例

就著上面說的登入登出流程,先提前看一段程式碼(有問題的):

 import { take, call, put } from 'redux-saga/effects'
 import Api from '...'

 function* authorize(user, password) {
  try {
    const token = yield call(Api.authorize, user, password)
    yield put({type: 'LOGIN_SUCCESS', token})
    return token
  } catch(error) {
     yield put({type: 'LOGIN_ERROR', error})
  }
 }

 function* loginFlow() {
   while(true) {
    const {user, password} = yield take('LOGIN_REQUEST')
    const token = yield call(authorize, user, password)
    if(token) {
      yield call(Api.storeItem({token}))
      yield take('LOGOUT')
      yield call(Api.clearItem('token'))
    }
   }
 }
複製程式碼

line 16: 當 LOGIN_REQUEST 的action被匹配時,拿到使用者名稱密碼就去呼叫 authorize 這個Generator (PS: call 不僅可以用來呼叫返回 Promise 的函式。我們也可以用它來呼叫其他 Generator 函式。 ) line 4-12: 拿到使用者名稱密碼之後就去執行真正的請求,這時候 authorize 就被阻塞了,等待著拿token;拿到 token 就 dispatch 登入成功,返回token;登入失敗就 dispatch 登入失敗 line 18-22: 登入成功之後就快取token,並且監聽登出的action,當匹配LOGOUT,則清楚token

上面的程式碼流程很清晰,就像閱讀同步程式碼一樣,自然順序確定了執行步驟,不用專門理解控制流(如果用takeEvery就會需要去理解)

但是,上面的程式碼有問題。 當使用者點登入之後,authorize 被阻塞,請求還沒返回,token還沒拿到,就在此刻,使用者又點了登出,那麼...

有問題的登入流程

上面程式碼的問題是 call 是一個會阻塞的 Effect。即 Generator 在呼叫結束之前不能執行或處理任何其他事情,然後,LOGOUT 與呼叫 authorize 是 併發的,導致出問題了

所以,需要本小節的主角登場 —— ☆ fork ☆

fork 一個 任務,任務會在後臺啟動,Generator不會被阻塞,呼叫者可以繼續它自己的流程,而不用等待被 fork 的任務結束。

具體改進如下:

 import { fork, call, take, put, cancel } from 'redux-saga/effects'
 import Api from '...'

 function* authorize(user, password) {
  try {
    const token = yield call(Api.authorize, user, password)
    yield put({type: 'LOGIN_SUCCESS', token})
    yield call(Api.storeItem, {token})
  } catch(error) {
     yield put({type: 'LOGIN_ERROR', error})
  } finally {
     if (yield cancelled()) {
       // 取消task之後的操作,比如取消loading之類
     }
   }
 }

 function* loginFlow() {
   while (true) {
     const {user, password} = yield take('LOGIN_REQUEST')
     const task = yield fork(authorize, user, password)
     const action = yield take(['LOGOUT', 'LOGIN_ERROR'])
     if (action.type === 'LOGOUT') {
       yield cancel(task)
     }
     yield call(Api.clearItem, 'token')
   }
 }
複製程式碼

yield fork 的結果是一個Task Object. line 21: 改用 fork api 呼叫 authorize ,loginFlow 就不會被阻塞 line 22:監聽 2 個併發的 action, line 22-26: 會有三種情況: 1、在登出之前,token已經拿到了,那麼會 dispatch LOGIN_SUCCESS,就結束了,就算在登出流程也是正常的 2、在登出之前,登入失敗了,那麼會 dispatch LOGIN_ERROR ,然後清除token,結束;進入另外一個 while 迭代等待下一個 LOGIN_REQUEST 3、token還沒拿到,使用者就登出了,那 loginFlow 會匹配到 LOGOUT ,取消掉 authorize 處理程式,清除token,然後就等待下一個 LOGIN_REQUEST 了 line 8: 使用 fork 之後就拿不到token了,因為不應該等待它,所以將 token 儲存操作移到 authorize 任務內部了 line 11-15:如果task被取消之後,你還需要做一些操作,比如Loading本來是true的,你想改成false,那可以利用canceled這個api來確定是否取消了

3、在多個 Effects 之間啟動 race eg: 觸發一個遠端的獲取請求,並且限制了 1 秒內響應,否則作超時處理

import { race, call, put } from 'redux-saga/effects'
import { delay } from 'redux-saga'
function* fetchPostsWithTimeout() {
  const {posts, timeout} = yield race({
    posts: call(fetchApi, '/posts'),
    timeout: call(delay, 1000)
  })
  if (posts) {
    put({type: 'POSTS_RECEIVED', posts})
  } else {
    put({type: 'TIMEOUT_ERROR'})
  }
}
複製程式碼

4、通過yield*進行排序

function* playLevelOne() { ... }
function* playLevelTwo() { ... }
function* playLevelThree() { ... }

function* game() {
  // 利用 yield* 組織saga的順序
  const score1 = yield* playLevelOne()  // ※
  yield put(showScore(score1))

  const score2 = yield* playLevelTwo()   // ※
  yield put(showScore(score2))

  const score3 = yield* playLevelThree()   // ※
  yield put(showScore(score3))
}
複製程式碼

更多高階概念,可直達這裡學習

返回

對比redux-thunk

一般情況下,action 都是符合 FSA 標準的(即:a plain javascript object),如下:

{
  type: 'ADD_TODO',
  payload: {
    text: 'Do something.'
  }
}
複製程式碼

含義:當執行dispatch(action)時,通知reducer,並且把action.payload (新狀態資料)action.type的方式(操作) 同步更新 到本地store。

但是,涉及請求的時候,payload一般來自於遠端服務端;然後redux-thunk就以 middleware 的形式來增強 redux store 的 dispatch 方法,(即支援 dispatch(function)),看下面程式碼:

// action.js
// -----------------
// 符合 FSA 的 action
export const setReplyModalData = (data) => {
    return { type: SET_REPLY_MODAL_DATA, payload:{data} };
};

// 這個 action return 的是一個function
// function 中包含了業務資料請求程式碼邏輯
export function fetchData(someValue) {
  return (dispatch, getState) => {
    myAjaxLib.post("/someEndpoint", { data: someValue })
      .then(response => dispatch({ type: "REQUEST_SUCCEEDED", payload: response })
      .catch(error => dispatch({ type: "REQUEST_FAILED", error: error });
  };
}

// component.js
// ------------
// View 層 dispatch(fn) 觸發非同步請求
// 這裡省略部分程式碼
this.props.dispatch(fetchData({ hello: 'saga' }));

複製程式碼

同樣的程式碼,redux-saga的實現: 它單獨起一個新檔案saga.js,然後把非同步action遷移到裡面

// saga.js
// ----------
// worker saga
// 它是一個 generator function
// function 中也包含了業務資料請求程式碼邏輯,但 是同步的寫法
function* fetchData(action) {
  const { payload: { someValue } } = action;
  try {
    const result = yield call(myAjaxLib.post, "/someEndpoint", { data: someValue });
    yield put({ type: "REQUEST_SUCCEEDED", payload: response });
  } catch (error) {
    yield put({ type: "REQUEST_FAILED", error: error });
  }
}

// watcher saga
// 監聽每一次 dispatch(action)
// 如果 action.type === 'REQUEST',那麼執行 fetchData
export function* watchFetchData() {
  yield takeEvery('REQUEST', fetchData);
}

// component.js
// -------
// View 層 dispatch(action) 觸發非同步請求
// 這裡的 action 依然可以是一個 plain object
this.props.dispatch({
  type: 'REQUEST',
  payload: {
    someValue: { hello: 'saga' }
  }
});

// action.js
// 然後action裡就保持了所有都是符合FSA的action了,更乾淨
export const setReplyModalData = (data) => {
    return { type: SET_REPLY_MODAL_DATA, payload:{data} };
};

複製程式碼

綜上,redux-saga對比redux-thunk的優點:

  • 副作用轉移到單獨的saga.js中,不再摻雜在action.js中,保持 action 的簡單純粹,又使得非同步操作集中可以被集中處理。

  • dispatch 的引數依然是一個純粹的 action (FSA),而不是充滿 “黑魔法” thunk function。

  • 每一個 saga 都是 一個 generator function,程式碼採用 同步書寫 的方式來處理 非同步邏輯(No Callback Hell),程式碼變得更易讀

  • 受益於 generator function 的 saga 實現,程式碼異常/請求失敗 都可以直接通過 try/catch 語法直接捕獲處理。

返回

額外補充

請求都要加上try catch,多考慮,避免網頁掛掉; 那什麼時候寫具體的catch呢?

感覺 如果是要獲取資料的時候最好寫清楚catch,因為這種情況下後端的toast一般都是網路請求失敗這種mw,免得拿不到資料就啥也做不了了,這時,作為前端,可以給到使用者一個友好的toast。如果後端沒返回errmsg,頁面也沒任何提示就特別不友好如果是建立、編輯、新增等,就不需要前端去做toast,在介面統一處去toast後端返回的error message,才可以toast具體的原因,比如群組重名了(這個是需要後端去查庫的)?還是什麼資料不合法?還是其他原因,就是提交型別的介面,在前端能做的表單校驗完成之後,還是有介面報錯,那是後端才能檢查出來的,就toast後端的丟擲來的問題;

最後總結一下

  • redux-saga就是一個redux的中介軟體,用於更優雅的管理非同步
  • redux-saga有一堆的api可供使用
  • 可以利用同步的方式處理非同步邏輯,便於捕獲異常,易於測試;

參考連結

Redux-Saga Tutorial

Redux-Saga Tutorial中文版

redux-saga 漫談

相關文章