Redux 應用程式的另一種 Side Effect 模型。代替 redux-thunk 發送的 thunk。你可以在一個地方建立 Sagas 來集中所有的 Side Effect 邏輯。
應用程式的邏輯會存在於 2 個地方:
-
Reducers 負責 actions 之間的狀態轉變。
-
Sagas 負責編排複雜/非同步的操作。
使用 Generator 函式來建立 Sagas。
接下來的內容,你將看見。Generators,雖然看起來比 ES7 aysnc 函式還低階(low level),但能提供像是陳述性作用(delcarative effects)、取消(cancellation)等功能。這些困難功能無法用單純的 async 函式實作出來。
此中介軟體提出了
-
可組合的抽象 Effect:等候某個 action;觸發 State 改變(透過分派 actions 到 store);呼叫某個遠端服務;等不同形式的 Effects。一個 Saga 可使用熟悉的流程控制組成(if、while、for、try/catch)組合這些 Effects。
-
Sage 本身就是一種 Effect。能夠過使用協調器(combinators)來組合其他 Effects。也可被其它 Sagas 呼叫,提供最大化的 Subroutines 及 Structured Programming。
-
Effects 可能會以陳述方式(declaratively)所引起(yielded)。你引起的是 Effect 的描述,中介軟體會負責執行它。讓你在 Generators 內的邏輯能夠充分地進行測試。
-
你可以實作複雜的邏輯操作,橫跨多個 actions(例如:用戶入職訓練、精靈對話框、複雜遊戲規則⋯),這些非一般的表達。
#開始入門
安裝
npm install redux-saga
創造 Saga(採用 Redux 的計數器範例)
import { take, put } from 'redux-saga'
// sagas/index.js
function* incrementAsync() {
while(true) {
// 等待每個 INCREMENT_ASYNC action
const nextAction = yield take(INCREMENT_ASYNC)
// delay 是範例函式
// 回傳 Promise,會在指定的毫秒(ms)後解決(resolves)
yield delay(1000)
// 分派 INCREMENT_COUNTER
yield put( increment() )
}
}
export default [incrementAsync]
將 redux-saga 接到中介軟體管道
// store/configureStore.js
import sagaMiddleware from 'redux-saga'
import sagas from '../sagas'
export default function configureStore(initialState) {
// Note: passing middleware as the last argument to createStore requires redux@>=3.1.0
return createStore(
reducer,
initialState,
applyMiddleware(/* other middleware, */sagaMiddleware(...sagas))
}
}
#等候未來的 actions
前一個範例中,我們創造了一個 等候未來的 actions
Saga。其中 yield take(INCREMENT_ASYNC)
的呼叫是一個 Sagas 如何運作的典型實例。
通常情況下,實際是由中介軟體們掌控這些 Effect 的構成,由 Action Creator 所觸發。舉例來說,redux-thunk 掌控 thunks,並將 (getState, dispatch)
作為參數帶入,redux-promise 掌控 Promises,分派其解決後的值。redux-gen 掌控 generators,分派所有引起(yielded)的 actions 到 store 之中。這裡所有的中介軟體都有個共通點,就是 '由每個 action 呼叫' 樣式。當 action 發生時,它們將會一次又一次的被呼叫,換言之,它們的範圍由觸發它們的 root action 決定。
Sagas 運作方式不同,並不是由 Action Creators 所觸發,而是與你的應用程式一起並決定哪個使用者 actions 需要關注(watch)。就像是在背景執行的服務,選擇自己的邏輯進展。在上述範例中,incrementAsync
使用 yield take(...)
來拉 INCREMENT_ASYNC
action。這是一種阻塞式呼叫,表示 Saga 不會繼續進行,直到收到符合的 action。
上述使用了 take(INCREMENT_ASYNC)
形式,表示正在等候 type 為 INCREMENT_ASYNC
的 action。
take
支援幾種樣式以便約束符合的 actions。yield take(PATTERN)
的呼叫根據以下規則進行掌控:
-
當 PATTERN 是 undefined 或
'*'
時。所有新進的 actions 都會符合(例如,take()
將會匹配所有的 actions) -
當 PATTERN 是 函式 時,只有當 PATTERN(action) 為真時,action 才會匹配。(例如,
take(action => action.entities)
將會匹配所有有entities
欄位的 action)。 -
當 PATTERN 是 字串 時,只有當 action.type === PATTERN 時才會匹配(如同上述範例的
take(INCREMENT_ASYNC)
) -
當 PATTERN 是 陣列 時,只有當 action.type 符合陣列中的其中一個元素時才會匹配(例如,
take([INCREMENT, DECREMENT])
將會匹配INCREMENT
或DECREMENT
。)
#派送 actions 到 store
接收到查詢的 action 之後,Saga 觸發 delay(1000)
呼叫,在這個範例中,回傳了 Promise,並會在 1 秒後解決。這是個組塞式呼叫,因此 Saga 將會等候 1 秒後繼續。
延遲之後,Saga 使用 put(action)
函式分派了一個 INCREMENT_COUNTER
action。相同地,這裡也將會等候分派後的結果。如果分派呼叫回傳的是一般的值,Saga 立即地恢復繼續(asap),但如果是個 Promise,則會等候 Promise 解決(或拒絕)。
#常見的抽象:Effect
為了一般化,等待未來的 action;等待未來的結果,像是呼叫 yield delay(1000)
;或者等待分派的結果,都是相同的概念。所有的案例都在引起某些 Effects 形式。
而 Saga 所做的事,實際上是將這些所有 effects 組合在一起,以便實作想要的控制流程。最簡單的方式是一個 yeidls 接著另一個 yields,循序引起 Effects。也可以使用熟悉的控制流程操作子(if、while、for)來實作複雜的控制流程。或者你想要使用 Effects 協調器來表達並發(concurrency,yield race)及平行(parallelism,yield [...])。甚至可以引起其他的 Sagas,讓你擁有強大的 routine/subroutine 樣式。
舉例來說,incrementAsync
使用無窮迴圈 while(true)
來表示將會永遠運作於應用程式的生命週期之內。
你也可以創造有限時間的 Sagas。例如,下列 Saga 等候前 3 個 INCREMENT_COUNTER
actions 並觸發 showCongratulation()
action,滿足後便會結束。
function* onBoarding() {
for(let i = 0; i < 3; i++)
yield take(INCREMENT_COUNTER)
yield put( showCongratulation() )
}
#陳述性 Effects
Sagas Generators 可以引起多種形式的 Effects。最簡單的是引起 Promise
function* fetchSaga() {
// fetch 是簡單函式
// 回傳 Promise 將會解決 GET 回應
const products = yield fetch('/products')
// 分派 RECEIVE_PRODUCTS action
yield put( receiveProducts(products) )
}
上述範例中,fetch('/products')
回傳 Promise 將會解決 GET 回應,所以 'fetch effect' 立即地執行。簡單且符合語言習慣,但是⋯
假設我們要測試上述 generator
const iterator = fetchSaga()
assert.deepEqual( iterator.next().value, ?? ) // 該期待什麼結果 ?
我們想要檢查 generator 第一個引起的結果,在這個案例中是執行 fetch('/products')
之後的結果。測試中執行實際的服務不是個可行的方式,也不是實踐的方法,所以我們需要仿製這個 fetch 服務,換言之,我們需要假的來替換真正的 fetch
方法,假的並不會實際發出 GET 請求,而是用來檢查是否用了正確的參數(在這個案例中,正確的參數為'/products'
)呼叫 fetch
。
仿製讓測試更加困難、更不可靠。另一方面,函式單純回傳值讓測試更容易,單純使用 equal()
來檢查結果。這是最可靠的撰寫測試方法。
不相信?鼓勵你閱讀 Eric Elliott 的這篇文章。
(...)
equal()
,就本質回答兩個最重要的問題,每個單元測試都必須回答,但大多數都不會回答:
- 實際輸出是什麼?
- 期望輸出是什麼?
如果你完成一個測試但沒有回答上述兩個問題,那就不是一個真正的單元測試。你有的只是一個草率的、不完整的測試。
而我們實際所需的,只是需要確保 fetchSaga
引起的呼叫,其呼叫的函式以及參數是正確的。因此,此函式庫提供一些陳述性的方式來引起 Side Effects,讓 Saga 的邏輯更容易測試
import { call } from 'redux-saga'
function* fetchSaga() {
const products = yield call( fetch, '/products' ) // 不會執行 effect
}
這裡使用了 call(fn, ...args)
函式。與先前範例的差異之處在於,並不會立即地執行呼叫 fetch,取而代之的是,call
創造出 effect 的描述。如同你在 Redux,使用 action creators 創造出 object 描述 action,將會被 Store 執行,call
創造出 object 描述函式呼叫。redux-saga 中介軟體負責函式呼叫並帶著解決的回應再開始 generator。
這讓我們在 Redux 環境以外也能夠容易地測試 Generator。
import { call } from 'redux-saga'
const iterator = fetchSaga()
assert.deepEqual(iterator.next().value, call(fetch, '/products')) // 預期的是 call(...) 值
現在,不再需要仿製任何事情。簡單的相等測試便足夠。
陳述式 effects 的優點是測試所有在 Saga/Generator 內的邏輯,簡單地反覆檢查迭代器的結果值,持續地對值進行單純的相等測試。真正的好處是,複雜的非同步操作不再是黑盒子,無論多複雜都可以詳細地測試,
呼叫某些物件的方法(即使用 new
創造),你可以用下列形式提供 this
上下文(context)到調用的函式
yield call([obj, obj.method], arg1, arg2, ...) // 如同我們使用 obj.method(arg1, arg2 ...)
apply
是個化名,用於方法調用形式
yield apply(obj, obj.method, [arg1, arg2, ...])
call
及 apply
適合在回傳 Promise 的函式。其他函式可用 cps
來處理 Node 風格函式(例如,fn(...args, callback)
其中 callback
是 (error, result) => ()
形式)。舉例來說
import { cps } from 'redux-saga'
const content = yield cps(readFile, '/path/to/file')
當然你也可以測試它
import { cps } from 'redux-saga'
const iterator = fetchSaga()
assert.deepEqual(iterator.next().value, cps(readFile, '/path/to/file') )
同樣 cps
支援相同的方法調用形式,如同 call
一樣。
#錯誤處理
Generator 裡面可以使用單純的 try/catch 語句來捕獲錯誤。在下列範例中,Saga 從 api.buyProducts
呼叫(即拒絕的 Promise)中捕獲錯誤
function* checkout(getState) {
while( yield take(types.CHECKOUT_REQUEST) ) {
try {
const cart = getState().cart
yield call(api.buyProducts, cart)
yield put(actions.checkoutSuccess(cart))
} catch(error) {
yield put(actions.checkoutFailure(error))
}
}
}
當然並不是強迫你要用 try/catch 區塊來處理你的 API 錯誤,你也可以讓 API 服務回傳一般值並帶有錯誤旗標
function buyProducts(cart) {
return doPost(...)
.then(result => {result})
.catch(error => {error})
}
function* checkout(getState) {
while( yield take(types.CHECKOUT_REQUEST) ) {
const cart = getState().cart
const {result, error} = yield call(api.buyProducts, cart)
if(!error)
yield put(actions.checkoutSuccess(result))
else
yield put(actions.checkoutFailure(error))
}
}
#Effect 協調器
yield
陳述式非常適合用來表示非同步控制流程,一種簡單且線性的風格。但是我們同樣地需要平行運作。無法單純的撰寫
// 錯誤,effects 將會依序執行
const users = yield call(fetch, '/users'),
repose = yield call(fetch, '/repose')
因為直到第 1 個呼叫解決之前,第 2 個 effect 並不會執行。取而代之,我們要寫成
import { call } from 'redux-saga'
// 正確,effects 將會平行地執行
const [users, repose] = yield [
call(fetch, '/users'),
call(fetch, '/repose')
]
當我們引起一個陣列的 effects,generator 將會阻塞直到所有 effects 都被解決(或者一旦其中有一個被拒絕,如同 Promise.all
行為)。
有時候平行發出多個任務並不希望等待所有任務都被解決,而是只需要一個 贏家:第一個被解決(或拒絕)。函式 race
提供了多個 effects 之間的競賽功能。
下列範例顯示 Saga 觸發了一個遠端擷取請求,並且限制該請求在 1 秒後超時。
import { race, take, put } from 'redux-saga'
function* fetchPostsWithTimeout() {
while( yield take(FETCH_POSTS) ) {
// 發出 2 個 effects 之間的競賽
const {posts, timeout} = yield race({
posts : call(fetchApi, '/posts'),
timeout : call(delay, 1000)
})
if(posts)
put( actions.receivePosts(posts) )
else
put( actions.timeoutError() )
}
}
#透過 yield* 的順序性 Sagas
你可以使用內建的 yield*
操作子依序組合多個 sagas。讓你可以用簡單的程序式風格來依序執行 macro-tasks
function* playLevelOne(getState) { ... }
function* playLevelTwo(getState) { ... }
function* playLevelThree(getState) { ... }
function* game(getState) {
const score1 = yield* playLevelOne(getState)
put(showScore(score1))
const score2 = yield* playLevelTwo(getState)
put(showScore(score2))
const score3 = yield* playLevelThree(getState)
put(showScore(score3))
}
請注意使用 yield*
會導致 JavaScript 的執行期間傳播整個順序。迭代器的結果會引起巢狀迭代器。更強大的代替方式是採用更一般化的中介軟體構成機制。
#組合 Sagas
雖然使用 yield*
提供一種語言習慣的方式來組合 Sagas。但這個方式有些限制:
-
你可能希望分開測試巢狀的 generators。這導致某些重複的測試程式碼產生以及重複執行的損耗。我們不希望執行一個巢狀 generator,只希望確保正確的參數呼叫。
-
更重要地,
yield*
只允許順序性的任務組成,你一次只能 yield* 一個 generator。
你可以簡單地使用 yield
來開始一個或平行多個子任務。當引起一個呼叫到 generator,Saga 將會等候 generator 終止才開始處理,接著使用回傳值再開始(或者拋出錯誤,當錯誤來自子任務)。
function* fetchPosts() {
yield put( actions.requestPosts() )
const products = yield call(fetchApi, '/products')
yield put( actions.receivePosts(products) )
}
function* watchFetch() {
while ( yield take(FETCH_POSTS) ) {
yield call(fetchPosts) // 等候 fetchPosts 任務結束
}
}
引起一個巢狀 generatos 的陣列將會平行地開始所有 sub-generators 並等候全部完成。接著用所有結果值再開始
function* mainSaga(getState) {
const results = yield [ call(task1), call(task2), ...]
yield put( showResults(results) )
}
事實上,引起 Sagas 與引起其他 effects 並無不同(未來 actions、超時 ⋯)。這表示你可以使用 effect 調節器結合這些 Sagas 及其他類型。
舉例來說,你希望使使用者在限定時間內完成某些遊戲
function* game(getState) {
let finished
while(!finished) {
// 必須在 60 秒內完成
const {score, timeout} = yield race({
score : call( play, getState),
timeout : call(delay, 60000)
})
if(!timeout) {
finished = true
yield put( showScore(score) )
}
}
}
#非阻塞式的呼叫 — fork/join
yield
陳述式造成 generator 暫停直到引起的 effect 被解決或拒絕。如果你近一點看看這個範例
function* watchFetch() {
while ( yield take(FETCH_POSTS) ) {
yield put( actions.requestPosts() )
const posts = yield call(fetchApi, '/posts') // 阻塞式呼叫
yield put( actions.receivePosts(posts) )
}
}
watchFetch
generator 將會等候直到 yield call(fetchApi, '/posts')
結束。想像 FETCH_POSTS
是透過一個 重整
按鈕觸發。如果我們應用程式在每個 fetch 之間關閉按鈕(非並發 fetch)那這樣不會有問題,因為我們知道不會有額外的 FETCH_POSTS
action 觸發直到取得 fetchApi
呼叫的回應。
但是如果應用程式希望允許使用者點擊 重整
按鈕,而不需要等候目前的請求結束?
下列範例描述了一個可能的事件順序
UI watchFetch
--------------------------------------------------------
FETCH_POSTS.....................呼叫 fetchApi............ 等待解決
........................................................
........................................................
FETCH_POSTS............................................. 遺漏
........................................................
FETCH_POSTS............................................. 遺漏
................................fetchApi 回傳............
........................................................
當 watchFetch
阻塞於 fetchApi
呼叫,所有在呼叫及回應之間發生的 FETCH_POSTS
都會被遺漏。
為了表達非阻塞式呼叫,我們可以使用 fork
函式。用 fork
改寫前述範例的一種可能寫法是
import { fork, call, take, put } from 'redux-saga'
function* fetchPosts() {
yield put( actions.requestPosts() )
const posts = yield call(fetchApi, '/posts')
yield put( actions.receivePosts(posts) )
}
function* watchFetch() {
while ( yield take(FETCH_POSTS) ) {
yield fork(fetchPosts) // 非阻塞式呼叫
}
}
fork
,就像是 call
,接受 函式/generator 呼叫。
yield fork(func, ...args) // 單純非同步函式 (...) -> Promise
yield fork(generator, ...args) // Generator 函式
yield fork(api)
的結果是個 任務描述子。為了在稍候能夠取得 forked 任務的結果,我們使用 join
函式
import { fork, join } from 'redux-saga'
function* child() { ... }
function *parent() {
// 非阻塞式呼叫
const task = yield fork(subtask, ...args)
// ... 稍候
// 現在是阻塞式呼叫,將會再開始帶著任務的結果
const result = yield join(task)
}
任務物件公開幾個有益的方法
方法 | 回傳值 |
---|---|
task.isRunning() | 回傳 true 當任務尚未回傳,或者拋出錯誤 |
task.result() | 任務回傳的結果。`undefined` 當任務仍然在執行中 |
task.error() | 任務拋出的錯誤。`undefined` 當任務仍然在執行中 |
task.done |
下列兩種其一的 Promise
|
#任務取消
當一個任務已經開始,你可以使用 yield cancel(task)
來終止執行。取消執行中任務將會導致內部拋出 SagaCancellationException
錯誤。
為了看如何運作,讓我們考慮一個簡單的範例。一個背景同步功能,可以透過某些 UI 命令來開始/暫停。根據接收 START_BACKGROUND_SYNC
action,我們將開始一個背景任務,週期性地從遠端伺服器同步某些資料。
任務持續的執行,直到 STOP_BACKGROUND_SYNC
action 觸發。接著將會取消背景任務並且再次等待下一個 START_BACKGROUND_SYNC
action。
import { take, put, call, fork, cancel, SagaCancellationException } from 'redux-saga'
import actions from 'somewhere'
import { someApi, delay } from 'somewhere'
function* bgSync() {
try {
while(true) {
yield put(actions.requestStart())
const result = yield call(someApi)
yield put(actions.requestSuccess(result))
yield call(delay, 5000)
}
} catch(error) {
if(error instanceof SagaCancellationException)
yield put(actions.requestFailure('Sync cancelled!'))
}
}
function* main() {
while( yield take(START_BACKGROUND_SYNC) ) {
// 開始一個任務於背景執行
const bgSyncTask = yield fork(bgSync)
// 等候使用者的停止 action
yield take(STOP_BACKGROUND_SYNC)
// 使用者點選了停止。取消背景任務
// 這會拋出 SagaCancellationException 例外到背景執行的任務
yield cancel(bgSyncTask)
}
}
yield cancel(bgSyncTask)
將會拋出 SagaCancellationException
在目前執行的任務之中。在上述範例中,例外由 bgSync
捕獲。注意:未被捕獲的 SagaCancellationException 不會向上冒起。如上述範例,若 bgSync
未捕獲取消例外,則例外將不會傳播到 main
(因為 main
已經繼續往下執行)。
取消執行中任務也會取消目前 effect,也就是取消當下的任務。
舉例來說,假設在應用程式生命週期的某個時間點,我們有個待定的呼叫鏈
function* main() {
const task = yield fork(subtask)
...
// 稍候
yield cancel(task)
}
function* subtask() {
...
yield call(subtask2) // 目前被此呼叫阻塞
...
}
function* subtask2() {
...
yield call(someApi) // 目前被此呼叫阻塞
...
}
yield cancel(task)
將觸發取消 subtask
,接著觸發取消 subtask2
。SagaCancellationException
將會拋到 subtask2
之中,接著拋到 subtask
之中。如果 subtask
省略對取消例外的處理,console 將會顯示顯示警告訊息來警告開發者(訊息只有當變數 process.env.NODE_ENV
設定為 'development'
的時候才會顯示)
取消例外的主要用意在於,讓被取消的任務可以執行清理邏輯。這讓應用程式不會在狀態不一致狀況下離開,在上述背景同步的範例中,透過捕獲取消例外,bgSync
能夠分派 requestFailure
action 到 store。否則,store 可能留下一種不一致的狀態(例如,等候待定請求的結果)
很重要的一件事,請記住
yield cancel(task)
並不會等候被取消的任務完成(即執行 catch 內的區塊)。cancel effect 行為像是 fork。一旦 cancel 被初始化之後便會返回。 一旦取消,一般情況下,清理的邏輯要盡快完成。某些情況下,清理的邏輯可能牽涉某些非同步的操作,但取消的任務是存在分開的 process,沒有辦法 rejoin 回到主要的控制流程(除了透過 Redux store 分派 actions 到其他任務。然而,這將帶領到複雜的控制流程,難以推理。更好的方式是盡可能的快速結束取消的任務)。
##自動的取消
除了手動取消之外。有某些案例會自動觸發取消
1- 在 race
effect 中。所有 race 競爭者,除了贏家,其餘皆會自動取消。
2- 在平行 effect(yield [...]
)中。一旦有一個 sub-effects 被拒絕,平行 effect 將很快的被拒絕(如同 Promise.all)。這個情況下,所有其他的 sub-effects 將會自動取消。
#動態啟動 Sagas — runSaga
runSaga
函示讓你能夠在 Redux 中介軟體環境之外開始 sagas。也讓你能夠勾到外部的 input/output,不同於 store actions。
舉例來說,你可以在伺服端開始一個 Saga
import serverSaga from 'somewhere'
import {runSaga, storeIO} from 'redux-saga'
import configureStore from 'somewhere'
import rootReducer from 'somewhere'
const store = configureStore(rootReducer)
runSaga(
serverSaga(store.getState),
storeIO(store)
).done.then(...)
runSaga
回傳一個任務物件。就像是 fork
effect 回傳的一樣。
與取得及分派 action 到 store 不同,runSaga
也可以連接到其他 input/output 來源。讓你可以在 Redux 以外的世界也能夠用 sagas 的功能來實作你的控制流程。
此方法的函數簽名如下
runSaga(iterator, {subscribe, dispatch}, [monitor])
參數
-
iterator: {next, throw}
: 迭代器物件,典型地使用 Generator 創造出來 -
subscribe(callback) => unsubscribe
: 換言之,接受回呼函示的函示,回傳取消訂閱的函示-
callback(action)
: 回呼函示(由 runSaga 提供)用來訂閱 input 事件。subscribe
必須支援註冊多個訂閱者 -
unsubscribe()
: 由runSaga
使用,一旦 input 來源完成之後,用來取消訂閱(一般的回傳或拋出例外)
-
-
dispatch(action) => result
: 用來實現put
effects。每當發出yield put(action)
,dispatch
將與action
一起調用。dispatch
的回傳值將用來實現put
effect。Promise 結果將自動地解決/拒絕。 -
monitor(sagaAction)
(optional):用來分派所有 Saga 相關事件的回呼函示。在中介軟體的版本中,所有 actions 將被分派到 Redux store。請見 [sagaMonitor 使用範例] (https://github.com/redux-saga/redux-saga/blob/master/examples/sagaMonitor.js).
subscribe
用來實現 take(action)
effect。每當 subscribe
發出 action 到其回呼函示,所有 sagas 將被 take(PATTERN)
阻塞,而取得符合目前進入的 action 樣式將會再開始動作。
#從原始碼組建範例
git clone https://github.com/redux-saga/redux-saga.git
cd redux-saga
npm install
npm test
下列範例(某個程度)從 Redux 存放庫移植
計數器範例
npm run counter
// generator 的測試
npm run test-counter
購物車範例
npm run shop
// generator 的測試
npm run test-shop
非同步範例
npm run async
// 抱歉,還沒有測試
真實世界範例(包含 webpack hot reloading)
cd examples/real-world
npm install
npm start
#在瀏覽器使用 umd 組建
redux-saga
有 umd 組建位於 dist/
目錄之下。使用 redux-saga
的 umd 組建可以從 window 物件下的 ReduxSaga
取得。當你沒有使用 webpack 或 browserify 時,umd 版本是非常有用的,你可以直接從 unpkg 取得。包含下列組建:
重要! 如果你的目標瀏覽器不支援 es2015 generators,你需要提供合適的 polyfill,例如,babel 所提供的:browser-polyfill.min.js。這個 polyfill 必須在 redux-saga 之前載入。