Skip to content

Latest commit

 

History

History
693 lines (491 loc) · 27.2 KB

README_zh-hant.md

File metadata and controls

693 lines (491 loc) · 27.2 KB

redux-saga

npm version

Redux 應用程式的另一種 Side Effect 模型。代替 redux-thunk 發送的 thunk。你可以在一個地方建立 Sagas 來集中所有的 Side Effect 邏輯。

應用程式的邏輯會存在於 2 個地方:

  • Reducers 負責 actions 之間的狀態轉變。

  • Sagas 負責編排複雜/非同步的操作。

使用 Generator 函式來建立 Sagas。

接下來的內容,你將看見。Generators,雖然看起來比 ES7 aysnc 函式還低階(low level),但能提供像是陳述性作用(delcarative effects)、取消(cancellation)等功能。這些困難功能無法用單純的 async 函式實作出來。

此中介軟體提出了

#開始入門

安裝

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]) 將會匹配 INCREMENTDECREMENT。)

#派送 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, ...])

callapply 適合在回傳 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 ,接著觸發取消 subtask2SagaCancellationException 將會拋到 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/yelouafi/redux-saga/blob/master/examples/sagaMonitor.js).

subscribe 用來實現 take(action) effect。每當 subscribe 發出 action 到其回呼函示,所有 sagas 將被 take(PATTERN) 阻塞,而取得符合目前進入的 action 樣式將會再開始動作。

#從原始碼組建範例

git clone https://github.com/yelouafi/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-sagaumd 組建位於 dist/ 目錄之下。使用 redux-saga 的 umd 組建可以從 window 物件下的 ReduxSaga 取得。當你沒有使用 webpack 或 browserify 時,umd 版本是非常有用的,你可以直接從 npmcdn 取得。包含下列組建:

重要! 如果你的目標瀏覽器不支援 es2015 generators,你需要提供合適的 polyfill,例如,babel 所提供的:browser-polyfill.min.js。這個 polyfill 必須在 redux-saga 之前載入。