PJCHENder 未整理筆記

[Redux] Saga

2018-12-22

[Redux] Saga

@(React)[React, Redux]

觀念

  • 在 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 被完成後才會繼續。也就是說,不論是 putcall 他們都沒有真正去執行 dispatch 或非同步的呼叫,他們只是回傳一個 JavaScript 物件:
  • Saga 可以透過很多不同的形式產生 effect,其中最簡單的一中方式就是 Promise。
1
2
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

1
$ npm install redux-saga

建立一支 sagas.js,在裡面定義好事件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 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
1
2
3
4
5
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 將會被取消。

:exclamation: takeLatest 並不表示只會送出一次的 request,而是當送出多個 request 時,會以最後一次送出的 request 所得到的回應為主,其餘的 request 一樣會被送出,但會被 cancel 掉。

另外 takeEverytakeLatest 都屬於 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 的值

1
2
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 請求:

1
2
3
function* fetchData() {
const data = yield fetch('https://example.com');
}

但因為 fetch 是直接回傳 Promise 的緣故,這樣的方式難以進行單元測試,在 Saga 中提供了幾個 declarative effect,這些 declarative effect 會回傳的是一個帶有指示的物件,如此,我們只需在測試中撰寫兩個物件是否相同:

1
2
3
function* fetchData() {
const data = yield call(fetch, 'https://example.com');
}

Declarative Effects @ Redux-Saga

更新 Store 資料:Dispatching Action

keywords: put

透過 AJAX 取得資料後,雖然一樣可以直接透過 Redux 中的 dispatch 來更新 store 中的資料:

1
2
3
4
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 中的資料

1
2
3
4
5
6
7
8
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

1
2
3
4
5
6
function* loginFlow() {
while (true) {
// 可以從 yield take 回傳的內容取的 payload
const { username, password } = yield take('LOGIN_REQUEST');
}
}

搭配 while 迴圈

實際上 takeEverytakeLatest 都是透過 take 這個方法所包出來的 higher level API。當我們使用 call 時在 middleware 的地方會停住 Saga,直到 Promise 被 resolve;使用 take 時類似,它會停住 saga 直到對應的 action 被 dispatch 後才繼續。

因此上面的寫法可以改寫成:

1
2
3
4
5
6
7
8
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 中要做哪些行為,舉例來說,登入登出一定是接續出現的,因此可以寫成:

1
2
3
4
5
6
7
8
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 的動作:

1
2
3
4
5
6
7
8
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

什麼是 blocking call

在 redux-saga 中,call 這個方法是一個阻塞式的效果(blocking effect),也就是說,直到某個 call 被處理完成前,並不能接著處理任何事

以下面的例子來說,有一個 call 是去呼叫 authorize 方法,但在這個 authorize 方法被處理完成取得 token 之前,iterator 會停在這個 yield 的位置;在這期間若有使用者 dispatch 了 LOGOUT 的 action,原本應該要被 take('LOGOUT') 的事件,便不會發生作用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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')
}
}
}

這種情況我們會說 LOGOUTauthorize 是「並行/同時發生(concurrent)」的。

使用 fork 達成 non-blocking calls

為了要解決這樣的問題,在 saga 中提供了另一個 Effect 稱作 fork當我們 fork 一個 task 時,這個 task 會在背景執行,Generator 中的 flow 將可以繼續進行,而不用等到該 forked task 結束後還能繼續

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 被取消的情況:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 屬於非阻塞性 Effectyield 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。
1
2
3
4
5
6
7
8
9
10
11
// 舉例來說,如果 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

在 Generator function 中的 yield call 會讓 task 停住,一行一行依序執行,但有些時侯我們需要平行處理多個 task,這時候我們可以用 yield all,這樣的寫法類似 Promise.all這個 Generator function 會阻塞(blocked)住,直到所有的 effects 都被完成,或者有任何一個被 rejected 的情況下才會繼續

1
2
3
4
5
6
7
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)在背景執行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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 的內容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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'})
}
}

:exclamation: yield raceyield all 一樣,因為裡面的非同步請求是 parallel 的,所以不需要再用 yield

可以參考這個 Commit @ sample-redux-saga

錯誤處理(Error Handling)

keywords: throw

在 Generator Function 中錯誤處理的方式一樣可以使用 try...catch...

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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:

1
2
3
4
5
6
7
8
9
// 精簡寫法
const delay = ms => new Promise(r => setTimeout(r, ms));

// 完整寫法
const delay = ms => new Promise((resolve) => {
setTimeout(() => {
resolve()
}, ms)
})
Tags: Redux

掃描二維條碼,分享此文章