[Redux] redux-saga 筆記
觀念
- 在 redux-saga 中,當在
yield
後接上Promise
時,middleware 會把 Saga 停住,直到這個 Promise 被 resolve 後才繼續執行。 - 一旦 Promise 被 resolve 之後,middleware 會讓 Saga 繼續執行到下一次的 yield 才停住。
- 在 redux-saga 中會從 Generator 中產生(yield)許多的 JavaScript 物件,這些物件包含了指示,因此我們把這樣的物件稱作「effect」,這些物件的指示會告訴 middleware 現在要執行哪些動作(例如,執行非同步的函式、向 store 發出 dispatch 等等)。
- 在 redux-saga 中有許多不同的 effect(例如,
put
),effect 是一個帶有指示的物件,需要透過 middleware 來完成,當 middleware 收到 Saga 產生(yield)的 effect 時,Saga 會停住,和 Promise 的情況相似,需要直到這個 effect 被完成後才會繼續。也就是說,不論是put
或call
他們都沒有真正去執行 dispatch 或非同步的呼叫,他們只是回傳一個 JavaScript 物件: - Saga 可以透過很多不同的形式產生 effect,其中最簡單的一中方式就是 Promise。
put({ type: 'INCREMENT' }); // => { PUT: {type: 'INCREMENT'} }
call(delay, 1000); // => { CALL: {fn: delay, args: [1000]}}
- 真正做事的地點是在 middleware 中,它會根據 effect 的類型來決定要如何完成這個 effect。如果 effect 是
PUT
的話,那麼他會 dispatch 一個 action 到 store 中;如果 effect 是CALL
,那麼就會呼叫被 給予的函式。
安裝與設定
安裝 redux-saga:
$ npm install redux-saga
建立一支 sagas.js
,在裡面定義好事件:
// sagas.js
import { delay } from 'redux-saga';
import { put, takeEvery, all } from 'redux-saga/effects';
export function* helloSaga() {
console.log('Hello Sagas!');
}
// 我們工作的 saga:將執行非同步的 increment task
// 延遲 1 秒後發出 type 為 'INCREMENT' 的事件
export function* incrementAsync() {
yield delay(1000);
yield put({ type: 'INCREMENT' });
}
// 我們觀察的 saga:當在 type 為 INCREMENT_ASYNC 時 就會執行 incrementAsync task
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync);
}
// 定義 rootSaga
export default function* rootSaga() {
yield all([helloSaga(), watchIncrementAsync()]);
}
在主要的 main.js
中載入 saga:
// main.js
// ...
import { createStore, applyMiddleware } from 'redux';
import createSagaMiddleware from 'redux-saga';
// ...
import rootSaga from './sagas';
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(rootSaga);
const action = (type) => store.dispatch({ type });
// rest unchanged
監控特定 Action
keywords: takeEvery
, takeLatest
, take
import { takeEvery, takeLatest } from 'redux-saga/effects';
function* watchFetchData() {
yield takeLatest('FETCH_REQUESTED', fetchData);
}
takeEvery
:每次觸發 type 為FETCH_REQUESTED
的 action 時都會執行fetchData
。takeLatest
:如果多次觸發 type 為FETCH_REQUESTED
的 action 時,會以最後一次的回應為準,在之前還未完成(如,pending)的fetchData
將會被取消。
❗
takeLatest
並不表示只會送出一次的 request,而是當送出多個 request 時,會以最後一次送出的 request 所得到的回應為主,其餘的 request 一樣會被送出,但會被 cancel 掉。
另外 takeEvery
和 takeLatest
都屬於 Higher Level API,若有需要更多的流程控制,建議可以參考 [take
](#Take API) 這個 lower level API。
處理非同步事件:Declarative Effect
keywords: call
, apply
, cps
call
後面的第一個參數可以是會回傳 Promise
的方法,或者可以接一個 Generator function;前者可以從 yield 取得 Promise resolve
的內容,[後者](#什麼是 blocking call)則可以取得該 Generator function 執行結束後最後 return
的值:
const data = yield call([obj, obj.method], arg1, arg2, ...) // as if we did obj.method(arg1, arg2 ...)
const data = yield apply(obj, obj.method, [arg1, arg2, ...])
雖然我們可以直接透過下面的方式來在 Saga 中進行 AJAX 請求:
function* fetchData() {
const data = yield fetch('https://example.com');
}
但因為 fetch 是直接回傳 Promise 的緣故,這樣的方式難以進行單元測試,在 Saga 中提供了幾個 declarative effect,這些 declarative effect 會回傳的是一個帶有指示的物件,如此,我們只需在測試中撰寫兩個物件是否相同:
function* fetchData() {
const data = yield call(fetch, 'https://example.com');
}
Declarative Effects @ Redux-Saga
更新 Store 資料:Dispatching Action
keywords: put
透過 AJAX 取得資料後,雖然一樣可以直接透過 Redux 中的 dispatch 來更新 store 中的資料:
function* fetchProducts(dispatch) {
const products = yield call(Api.fetch, '/products');
dispatch({ type: 'PRODUCTS_RECEIVED', products });
}
但這麼做一樣會使得我們難以撰寫單元測試,為了解決這樣的問題,和 declarative effect 解決問題的方法相通,可以先建立一個 JavaScript 物件,透過這個物件再來告訴 middleware 並由 middleware 中執行 dispatch 。
在 saga 中,可以透過 put
這個方法來產生 dispatch Effect 來更新 store 中的資料。
import { call, put } from 'redux-saga/effects';
// ...
function* fetchProducts() {
const products = yield call(Api.fetch, '/products');
// create and yield a dispatch Effect
yield put({ type: 'PRODUCTS_RECEIVED', products });
}
put
:和dispatch
的效果相同,只是如果在 generator function 中直接使用dispatch
,將難以進行測試。
Dispatching Actions @ Redux-Saga
Lower level API
Take API
keywords: take
Pulling future action @ Redux Saga
透過 take
可以監聽某一 action,並且從該 yield 的 function 可以取得 action 的 payload
:
function* loginFlow() {
while (true) {
// 可以從 yield take 回傳的內容取的 payload
const { username, password } = yield take('LOGIN_REQUEST');
}
}
搭配 while 迴圈
實際上 takeEvery
和 takeLatest
都是透過 take
這個方法所包出來的 higher level API。當我們使用 call
時在 middleware 的地方會停住 Saga,直到 Promise 被 resolve;使用 take
時類似,它會停住 saga 直到對應的 action 被 dispatch 後才繼續。
因此上面的寫法可以改寫成:
import { take } from 'redux-saga/effects';
function* watchFetchData() {
while (true) {
const action = yield take('FETCH_REQUESTED');
// 做原本 fetch Data 要執行的動作 ...
}
}
留意這裡用的 while (true) { … }
的寫法,因為 watchFetchData
是一個 Generator function,透過 while (true)
的 寫法可以讓這個 Generator function 不會進到 done: true
的情況,因此每次疊代時都會等待該 action 產生。
如果沒有加上
while (true)
這個 Generator function 產生的 iterator 將執行一輪後就結束了,如此將只會監聽到一次該 action 的發生,之後便無法持續監聽某一 action。
透過 take
更可以控制一個 Generator Function 中要做哪些行為,舉例來說,登入登出一定是接續出現的,因此可以寫成:
function* loginFlow() {
while (true) {
yield take('LOGIN');
// ... perform the login logic
yield take('LOGOUT');
// ... perform the logout logic
}
}
如此在一個 loginFlow
的 Generator function 中便能完成 Login 和 Logout,但如果使用的是 takeEvery
的話,則需要拆成兩個方法分別來做 login 和 logout 的行為。
搭配 for 迴圈
如果需要限制某一 action 觸發幾次後才執行某一動作的話,則可以搭配使用 for 迴圈,在下面了例子中,會在 TODO_CREATED
的 action 被 dispatch 三次後,才執行 put
的動作:
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' });
}