[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' });
}
將 takeEvery, takeLatest 用 lower level API 撰寫
Common Concurrency Pattern @ redux-saga
Non Blocking Calls
keywords: fork
, cancel
, cancelled
- 初步了解 fork, cancel 的使用情境:Non blocking calls @ redux-saga
- 更多關於 cancel 的細節:Task cancellation @ redux-saga
什麼是 blocking call
在 redux-saga 中,call
這個方法是一個阻塞式的效果(blocking effect),也就是說,直到某個 call 被處理完成前,並不能接著處理任何事。
以下面的例子來說,有一個 call
是去呼叫 authorize
方法,但在這個 authorize 方法被處理完成取得 token
之前,iterator 會停在這個 yield
的位置;在這期間若有使用者 dispatch 了 LOGOUT
的 action,原本應該要被 take('LOGOUT')
的事件,便不會發生作用:
import { take, call, put } from 'redux-saga/effects';
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');
// 直到 authorize 被處理完成前,並沒有辦法接著做這個函式內的其他事
const token = yield call(authorize, user, password);
if (token) {
yield call(Api.storeItem, { token });
yield take('LOGOUT');
yield call(Api.clearItem, 'token');
}
}
}
這種情況我們會說
LOGOUT
和authorize
是「並行/同時發生(concurrent)」的。
使用 fork 達成 non-blocking calls
為了要解決這樣的問題,在 saga 中提供了另一個 Effect 稱作 fork
,當我們 fork 一個 task 時,這個 task 會在背景執行,Generator 中的 flow 將可以繼續進行,而不用等到該 forked task 結束後還能繼續:
import { fork, call, take, put } from 'redux-saga/effects';
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 });
}
}
function* loginFlow() {
while (true) {
const { user, password } = yield take('LOGIN_REQUEST');
yield fork(authorize, user, password);
yield take(['LOGOUT', 'LOGIN_ERROR']);
yield call(Api.clearItem, 'token');
}
}
可以把
fork
的行為想成一個非同步事件,射後不理,因此可以繼續往下一個 yield 進行。
但上面這種做法還有小小的問題,雖然現在不會因為拿不到 token 而被阻塞而無法接受到 LOGOUT
的 action,但當使用者發出了 logout action 後,因為 authorize
仍在背景持續進行,因此可能導致使用者發出了 logout action 後,最終卻仍走到 LOGIN_SUCCESS
。
使用 cancel 終止進行中的事件
為了解決這樣的問題,停止 forked task,在 saga 中可以使用 cancel
這個 Effect。
透過 const task = yield fork(<task>)
Effect 會回傳該 Task 的物件,接著可以在需要的時候,透過 yield cancel(task)
的方式來停止該 task。
import { take, put, call, fork, cancel } from 'redux-saga/effects';
// ...
function* loginFlow() {
while (true) {
const { user, password } = yield take('LOGIN_REQUEST');
// 透過 fork 方法會回傳該 Task 物件
const authorizeTask = yield fork(authorize, user, password);
const action = yield take(['LOGOUT', 'LOGIN_ERROR']);
if (action.type === 'LOGOUT') {
// 取消原本的 authorize task
yield cancel(authorizeTask);
yield call(Api.clearItem, 'token');
}
}
}
使用 finally 和 cancelled 來處理被停止的 task
在 authorize
這個 Generator function 中,可以使用 finally
來做最後的收尾動作,由於不論該 task 最後是否有成功、錯誤(error)或被 saga 取消時都會進入 finally
,因此在 Saga 中也提供一個 cancelled
Effect,可以專門處理當該 Task 被取消的情況:
import { take, call, put, cancelled } 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 });
return token;
} catch (error) {
yield put({ type: 'LOGIN_ERROR', error });
} finally {
// 不論如何都會進到 finally block 內...
if (yield cancelled()) {
// 如果此 Task 是被 cancel 的話,那麼則...
}
}
}
關於 cancel 的注意事項
需要留意的是:
- cancel 屬於非阻塞性 Effect:
yield cancel(task)
的行爲和yield fork(task)
類似,都是屬於射後不理的,因此並不會等到該 task 被cancelled
才繼續執行後面的動作,參考這裡。 - 自動 cancel:當使用
yield race({ task1, task2 })
和yield all([task1, task2])
時,前者race
的情況只要有一個 task 被完成後另一個就會自動被 cancel;後者all
的情況,一旦其中一個 task 被 reject,和Promise.all
類似,所有其他的 task 都會一併被 cancel。
// 舉例來說,如果 yield all 裡面的 throwError 發生了錯誤,那麼 watchAndLog 也會一併被 cancel
export default function* loggerSaga() {
try {
yield all([watchAndLog(), throwError()]);
} catch (ex) {
console.log(ex.message);
}
}
Task Cancellation @ redux-saga
fork 和 all 都可以用來平行處理多個 task
實際上, 有些情況 fork
的效果可以用 all
來完成,可以參考下面的連結說明:
Fork Model @ redux-saga
一次啟動多個 Saga Task
keywords: all
- Using Saga Helpers @ redux-saga
- Running Tasks is Parallel @ redux-saga
在 Generator function 中的 yield call
會讓 task 停住,一行一行依序執行,但有些時侯我們需要平行處理多個 task,這時候我們可以用 yield all
,這樣的寫法類似 Promise.all
,這個 Generator function 會阻塞(blocked)住,直到所有的 effects 都被完成,或者有任何一個被 rejected
的情況下才會繼續:
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')
])
如果你有多個 saga 在監控多個不同的 actions,可以這麼做讓多個 saga 同時(parallel)在背景執行:
import { takeEvery } from 'redux-saga/effects'
// FETCH_USERS
function* fetchUsers(action) { ... }
// CREATE_USER
function* createUser(action) { ... }
// 當使用 saga 內建的 helpers 時會類似 `fork` 的效果,
// 這些方法不會被阻塞,而是會繼續接著執行
export default function* rootSaga() {
yield takeEvery('FETCH_USERS', fetchUsers)
yield takeEvery('CREATE_USER', createUser)
}
或者可以使用 all
這個 help:
import { put, takeEvery, all } from 'redux-saga/effects'
const delay = (ms) => new Promise(res => setTimeout(res, ms))
function* helloSaga() {...}
export function* incrementAsync() {...}
export function* watchIncrementAsync() {
yield takeEvery('INCREMENT_ASYNC', incrementAsync)
}
// notice how we now only export the rootSaga
// single entry point to start all Sagas at once
export default function* rootSaga() {
yield all([
helloSaga(),
watchIncrementAsync()
])
}
在多個效果中只取最快完成的
keywords: race
Starting a race between multiple Effects @ redux-saga
有些時候當我們一次發出很多平行的任務(parallel tasks)時,只想要取得最快完成的那個,這時候可以使用 race
這個 Effect。
這很適合用來作為 Timeout Error 的處理,yield race({})
會回傳 Promise resolve
的內容,或 Generator function 最後 return
的內容:
import { race, call, put, delay } from 'redux-saga/effects';
function* fetchPost() {
const resOfFetchPost = yield call(fetch, `${BASE_URL}/posts/1`);
if (!resOfFetchPost.ok) {
const error = yield resOfFetchPosts.json();
throw error;
}
const data = yield resOfFetchPosts.json();
// return 的內容將回到 yield race 的回傳值
return data;
}
function* fetchPostsWithTimeout() {
const { posts, timeout } = yield race({
// race 裡面不需要再使用 yield
posts: call(fetchApi, '/posts'), // fetchAPI resolve 的內容
post: call(fetchPost), // fetchPost Generator return 的內容
timeout: delay(1000),
});
if (posts) {
yield put({ type: 'POSTS_RECEIVED', posts });
} else {
yield put({ type: 'TIMEOUT_ERROR' });
}
}
❗
yield race
和yield all
一樣,因為裡面的非同步請求是 parallel 的,所以不需要再用yield
。
可以參考這個 Commit @ sample-redux-saga
錯誤處理(Error Handling)
keywords: throw
在 Generator Function 中錯誤處理的方式一樣可以使用 try...catch...
:
import Api from './path/to/api';
import { call, put } from 'redux-saga/effects';
// ...
function* fetchProducts() {
try {
const products = yield call(Api.fetch, '/products');
yield put({ type: 'PRODUCTS_RECEIVED', products });
} catch (error) {
yield put({ type: 'PRODUCTS_REQUEST_FAILED', error });
}
}
另外在 saga 中有一個經驗法則,不要在 forked tasks 內去 catch 錯誤,而是應該在會阻塞的函式內所 catch 錯誤。
在撰寫測試時,如果想要觸發錯誤,可以使用 iterator 的 throw
方法。
Error Handling @ redux-saga
Utilities
Delay
在 redux-saga 中 yield
後面如果接的是 Promise 的話,會等該 promise resolve
之後才繼續執行,因此可以透過 Promise 搭配 setTimeout
撰寫一個 delay function:
// 精簡寫法
const delay = (ms) => new Promise((r) => setTimeout(r, ms));
// 完整寫法
const delay = (ms) =>
new Promise((resolve) => {
setTimeout(() => {
resolve();
}, ms);
});