這是篇長文,你可以直接跳到你想看的地方就好
或是直接在 github 上面看我 step by step 的教學
redux-thunk-to-saga-tutorial

先把結論講在一開始,這並不只是一個 library 的使用方法介紹而已,

因為學習 saga pattern 對於前端工程師是有幫助的,

主要不出以下三個概念:

  • 好的 UI/UX 該是一個畫面的 transaction

  • User 隨時能夠取消 transaction

  • 滿足上述條件實作出來的資料流是要容易被測試的

redux-saga到底是在解決什麼問題呢?

答案:

  • 讓我們的非同步 action 能夠更好被開發、維護、測試。

  • 讓我們用不同的方式來思考非同步的前端資料流

Adventure time

saga 的中文翻譯是冒險故事

這裡來舉個例子:我們要登入

1
2
3
4
5
6
7
8
9
10
送出登入 request =>
畫面進入 loading 畫面 =>
if (登入成功) {
取得並把 token 快取起來 =>
拿到`username`以及對應的`token` =>
done
} else {
顯示錯誤訊息在首頁上
done
}

你會怎樣去設計這個資料流呢?

畫面要有什麼 state ?

假如登入要可以取消,你要怎樣改變畫面的 state 呢?

這個流程看似簡單,

但要處理的乾淨、又好測試,

是不是事情就沒有那麼直覺了?

目前看起來好像很抽象,但瞭解後,

redux-saga 並沒有什麼神奇的黑魔法。

我不認為 redux-saga 的只是拿來取代 redux-thunk的工具,

重要的應該是 saga 這個 pattern 背後的概念,

給了你新的方式去思考前端資料流。

送出資料 => loading 動畫 => 完成

其實前端的畫面也隱含著 transaction 的概念在裡面。

我認為如果有出現以下幾個現象,

redux-saga 值得你一試:

  • 學會 generator function 卻無處可應用

  • 處理非同步的 action 時,總覺得哪裡怪怪的 => 回傳 promise 時要怎麼測試

  • 純粹好奇 redux-saga能幫助你什麼

Catalogue

Introduction

有些人會說 redux-saga 的學習曲線比較陡峭,

其實並不盡然。

會覺得 redux-saga 太過困難,

通常就是因為一次就想直接學會、並應用,

忽略有些預先知識必須要一步一步學習,

而且有些情況,必須拉高一點視角會比較好看清楚,

從概念的角度去看,而不是只關注在前端的實作。

我認為這裡只有三件事情要掌握

  • 什麼是 saga?

  • saga 跟前端開發有什麼關係?

  • redux-saga 的基礎用法

什麼是 Saga

要學一個東西,把名詞搞懂是很重要的。

像 router 就是個很直覺又常見的名詞,

saga 是什麼呢?

redux-saga 有提供一些資源供參考,

包括了最原始提出 saga 這個 pattern 的論文

一共 11 頁,不過扣掉 acknowledgment 跟 References ,

就只有 9 頁半啦!

不過論文中是從 Database 的角度看,

另一個影片,是從應用在分散式系統的角度去解釋,

提高了不少複雜度。

基於以前端的角度,這篇講解 saga 主要會以 paper 上為主。

saga 其實是個很簡單的概念,

要應用它也並不困難,

這篇論文在 DBMS 上實作的原因,

主要只是要闡明如何實做一個簡潔、有效率的 sagas,

所以不要擔心接下來講的例子看起來跟 redux 或前端開發沒有關係,

稍後會提到要怎樣在前端開發中應用 saga 這個 pattern。

所以看個幾分鐘之後,腦袋裡會冒出許多的問號:「所以 saga 是⋯⋯?」。

這裡我試著用最簡單的語言解釋 saga 是什麼。

Saga,就是個滿足特殊條件的 LLT(Long lived transaction)。

待會會說是什麼特殊條件。

如果你不知道什麼是 Transaction:

是 Database 上常會用到(但不僅止侷限於 Database)的名詞,

即是「交易」。

「交易」聽起來很抽象,

其實他要敘述的就是銀貨兩訖後,

一個交易才算是完成,

假如銀貨不兩訖的話,那要退回最一開始的時候,

買賣雙方的狀態會退回交易前的狀態,不會有任何改變。

Long lived transaction (LLT)有什麼問題

Long lived transaction 是什麼呢?

而 LLT 就是一個長時間的 transaction,

就算沒有受到其他影響,

整個完成可能也需要數小時或數天。

聽起來,似乎是很糟糕的概念對吧?

因為為了實現 transaction,我們通常會把正在 transaction 中的 object lock 住,

讓其他人沒辦法更動它。

(維持資料的 consistency)

所以這麼長時間的 transaction,

會造成兩個問題:

  • 較高的失敗率

  • dead lock 造成的長時間 delay

舉個很實際的例子,就是江蕙演唱會的訂票。

購票的時間可能會是某一段時間,

而我們最終要確認訂票的數,這就會是一個 LLT。

為解決這個問題,

我們這裡可以假設這個 LLT:T

可以被拆成許多相互獨立的 subtransaction的集合:
t_1~t_n

但如果我們不會希望t_1~t_n分別被送進 DB 並且記錄下來。

以上述江蕙演唱會的例子,
每個小t就會是每筆訂票紀錄

如下圖:

first state

假如每個 transaction 都一次就成功,

而且沒有人退票的話,那個 transaction 就會正常的被執行:

all success

因為假如有一個失敗的話,

T 就不算是完成的 transaction。

儘管如此,這樣做也比一般的 transaction 帶來了一些彈性,

我們可以隨意的插入 subtransaction。

接著就來解釋 saga 運用什麼樣的設計方式來解決這些問題。

Saga 是一種特殊的 LLT

第一件要注意到的事就是 saga 仍然是個 LLT。

saga: LLT that can be broken up into a collection of subtransactions that can be iterleaved in any way with other transactlons

作為一個 LLT,

假如任何一個 saga 中的 subtransaction: t_i 單獨執行了,

我們應該要有一個 compensating transaction c_i 可以將它 undo。

這裡的 compensating transaction,

指的是從語意上的觀點來看,

而不是整個系統都得還原到 t_i 發生的那個時間點。

再看一次上面這段話,魔鬼就藏在細節裡,

這正是 saga 為什麼可以解決 LLT 問題的關鍵。

你可能會覺得這兩件事不是差不多嗎?

舉個例子:

如果有個 LLT : T 是要記住所有買江蕙票的座位數,

底下每個訂票都是一個 subtransaction: t

假設 t_i 要被買票的人取消,

我們執行 c_i時,

只是把買的座位數從 database 裡面減掉

而不是讓 database 回到 t_i發生前的時間點

所以我們可以得到一個簡單的公式,

Saga’s gurantee:

  • 如果全部都執行成功(Successful saga):

    • t_1, t_2…., t_n

示意圖:

success gif

  • 失敗的話(Unsuccessful saga):

    • t_1, t_2…., t_n, c_n…, c_1

failed

這裡可以注意到其實 c4 是沒有做任何事情的,

在實作時候如果是最後一個 transaction failed 掉的話,可以忽略 c4

不過就算執行了也不應該會出錯

因為每個執行應該都是 idempotent(冪等)的

如此一來我們就掌握了對 saga 的基本知識了!

在進入redux-saga前,先來看看我們會遇到什麼問題

Front-end perspective

Login flow

講了這麼多抽象概念的事情,

讓我們回到實務上來看,

來看最開始的這個例子:

1
2
3
4
5
6
7
8
9
10
送出登入 request =>
畫面進入 loading 畫面 =>
if (登入成功) {
取得並把 token 快取起來 =>
拿到`username`以及對應的`token` =>
done
} else {
顯示錯誤訊息在首頁上
done
}

畫面出來大概是這樣:

login flow

以下部分你可能必須要熟悉 redux

或是任何單向資料流的架構,

我盡量不預設讀者有任何預備知識來寫以下的文章 XD

不過真的不行的時候,會放上參考資料

在 redux 中,如果要改變畫面的狀態(state),

我們必須 dispatch 一個 action 到 store 去,

而對應的 reducer 會根據 action 幫我們生出下一個 state,

並且將 store 中的 state 更新成對應的新 state。

reducer(state , action) => nextState

假如還是很模糊的話,可以看看 redux 優秀的文件:

redux

來看一下 login 的 reducer 會長什麼樣子:

這裡為了簡化,有刪去一些東西

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function login(state = {
status: 'init'
}, action) {
switch (action.type) {
case LOGIN_REQUEST:
return {status: 'loading'}
case LOGIN_SUCCESS:
return {
status: 'logined',
username: action.response.username,
token: action.response.token
}
case LOGIN_ERROR:
return {
status: 'error',
error: action.error
}
default:
return state
}
}

歸類成以下幾個結果:

  • LOGIN_REQUEST:當我們送出LOGIN_REQUEST這個 action 時,會進入 loading 狀態

  • LOGIN_SUCCESS:登入成功,會拿到 username 以及對應的 token

  • LOGIN_ERROR:登入失敗,會拿到錯誤訊息

那真正執行的時候該如何執行呢?

Redux thunk 的解法與問題

Thunk?Is it good to drink?

來看一下維基百科的解釋:

In computer programming, a thunk is a subroutine that is created, often automatically, to assist a call to another subroutine.

只截錄一小段,剩下的多看也只是搞混。

簡單說就是我們為了把一個 subroutine A 的工作,

帶到另一個 subroutine B 做完,

中間需要一個橋樑:subroutine C,

這個 C 就是 thunk 啦!

在 redux 中,我們如果要讓一個 action 能夠更新,

必須要 dispatch 它。

所以上述的login流程大概會長這個樣子:

1
2
3
4
5
6
7
8
9
10
11
12
function loginFlow({username, password}) {
return (dispatch) => {
dispatch(loginRequest())
loginAPI({username, password})
.then(response => {
dispatch(loginSucess(response))
})
.catch(error => {
dispatch(loginError(error))
})
}
}

loginRequest 是一個 action creator,
會回傳 {type: LOGIN_REQUEST}這個 object。

這裡回傳的就是一個 thunk,

因為我們在這個 action 裡面同時得完成:

  • 送request

  • 收到 response data

  • 處理錯誤

所以我們必須把 dispatch 給傳進來,

完成原本只靠單個 subroutine(一般的 action creator) 無法做到的事情。

這裡有什麼問題呢?

  • 你要如何去測試這個一連串的動作?

  • 這裡回傳的是一個 promise,它無法被 abort,如果我們今天想加上取消按鈕呢?

    • 更 low level 一點的問法:你要在哪裡 dispatch loginCancel這個 action 呢?

當然, login 是一個相對簡易的流程,

假如遇到有更多 state 要處理,

無法寫出測試以及不那麼直覺的語法,

將會為我們的開發帶來一些問題。

Front-end 中的 saga

這裡的一整個 loginFlow,其實就是一個 LLT(長時間的 transaction),

Long lived transaction 是什麼?

可以看完這一段再回到這裡 XD

底下的 subtransaction 就是各個 action(request, success, error)。

有了這樣的概念之後,剩下來的事就簡單多了。

而且 saga 就是底下每個 transaction 都附帶 compensating transaction 的 LLT,

也就是說上述的 abort ,在 saga pattern 之下是內建的。

Refactor with redux-saga

Setup

這裡跟概念比較沒關係,

但環境設定絕對是許多人卡關的第一步。

首先要建立一個 sagas 資料夾,

底下有一個 rootSaga,它會是一個 generator function:

1
2
3
4
5
export default function* rootSaga() {
yield [
// to be done
]
}

接著在 middleware 中將它跑起來。

1
2
3
4
5
6
7
8
9
import rootSaga from './sagas'
const sagaMiddleware = createSagaMiddleware()
const store = createStore(rootReducer,
applyMiddleware(thunkMiddleware, sagaMiddleware)
)
sagaMiddleware.run(rootSaga)

這裡的基本設定,其實每次都大同小異,

所以就不再多著墨底下發生什麼事情。

Effect

前面有提到的 subtransaction,可以很粗略的對應到這裡的 effect

saga 不出以下幾種情形:

  • 監聽 action 發生 -> take, takeEvery

  • 執行 transaction -> put

  • 取消 transaction -> cancel

右邊的就是我們在 redux-saga 中對應到的 helper function,

他們就是 action creactor 一樣,會回傳一個物件,

不過這一次是回傳一個 effect ,而不是 action,

e.q: take({type: LOGIN_REQUEST}) 就是產生一個拿到 loginRequest 的 effect。

接著就來把 code 改寫吧!

Watch action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import {
takeEvery
} from 'redux-saga/effects'
import {
LOGIN_REQUEST,
LOGIN_SUCCESS,
LOGIN_ERROR
} from '../actions/login.js'
export function* watchRequestLogin() {
yield takeEvery(LOGIN_REQUEST, loginFlow)
}
export function* loginFlow() {
// to be done
}

值得注意的是這裡都是 generator function,

假如你完全對 generator function 沒有概念的話,

推薦你看這篇文章

是我寫的 XD

這裡的 code 還蠻語義化的,

就是當我們遇到一個 LOGIN_REQUEST 的 action ,

就會執行 loginFlow 這個 function。

接著是前面提到的好測試,

我們來測試這個 saga 吧!

1
2
3
4
5
6
7
8
9
10
describe('Sagas/ login', () => {
describe('watchRequestLogin', () => {
const iterator = watchRequestLogin()
it('should take every login request', () => {
const expected = takeEvery(LOGIN_REQUEST, loginFlow)
const actual = iterator.next().value
assert.equal(expected.name, actual.name)
})
})
})

這裡比較 tricky 是我們測試的是 effect 的名字,

為什麼不是直接 deepEqual 兩個 effect?

我們回傳的 effect 其實就是個 object,長相是下面這樣:

1
2
3
{ name: 'takeEvery(LOGIN_REQUEST, loginFlow)',
next: [Function: next],
throw: [Function] }

只要 name 是對的,我們就知道他在對應的 LOGIN_REQUEST進來時,

會執行loginFlow 這個 function。

而且在JavaScript中會判斷這兩個 next 是不同 function XD

直接測試名字,是我現在想到比較直觀的方法

Migrate Login Flow to saga

Talk is cheap:

1
2
3
4
5
6
7
8
9
10
11
12
export function* loginFlow(action) {
try {
const response = yield call(loginAPI, {
username: action.username,
password: action.password
})
yield put({type: LOGIN_SUCCESS})
}
catch(error) {
yield put({type: LOGIN_ERROR, error})
}
}

call 跟我們熟悉的 Function.prototype.call 很像!

不一樣的是,這裡的 call 會回傳的是一個 effect

這代表什麼?代表我們能夠很好的測試它,

而不是真的去 call loginAPI,帶來了無止盡的 mock。

我們把 loginFlow 的 test 拆成四個部分來看

  • Initialize

  • Call loginAPI

  • Handle login success

  • Handle login error

前面的 watch function 會把 request 這個 action 丟進來這裡,

所以我們要先製造出一個待會會用到的 iterator:

執行 Generator function 會返回一個 iterator,
然後我們去對這個 iterator 呼叫 next function
感謝 CT 的指正。

1
2
3
4
5
const iterator = loginFlow({
type: LOGIN_REQUEST,
username: 'denny',
password: '12345678'
})

再來則是 call API,注意我們測試的是 call effect,

而不是真的去呼叫這個 API:

1
2
3
4
5
6
7
8
it('should call loginAPI', () => {
const expected = call(loginAPI, {
username: 'denny',
password: '12345678'
})
const actual = iterator.next().value
assert.deepEqual(expected, actual)
})
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
it('should handle login success', () => {
const getResponse = () => ({
username: 'denny',
token: 'fake token'
})
const expected = put({
type: LOGIN_SUCCESS,
response: {
username: 'denny',
token: 'fake token'
}
})
const actual = iterator.next(getResponse()).value
assert.deepEqual(expected, actual)
})

這裡我們可以運用 generator 的特性來把假 error 丟進去XD

裡面的 catch 接到 error 之後,就會執行 login error 的流程了。

1
2
3
4
5
6
7
8
9
it('should handle login error', () => {
const error = 'error message'
const expected = put({
type: LOGIN_ERROR,
error: 'error message'
})
const actual = generator.throw(error).value
assert.deepEqual(expected, actual)
})

Combine loginFlow saga

首先要把 login 的 saga 接到 root saga 去

接著我們要來把原本 dispatch 的 loginFlow action 換成 loginFlowSaga 了。

1
2
3
4
5
6
7
import {watchRequestLogin} from './login.js'
export default function* rootSaga() {
yield [
watchRequestLogin()
]
}

再來我們只要把原本放 loginFlow action 的地方,

換成 loginRequest 這個相對簡單的 action creator 就行了。

這樣也更符合實際在運作的方式,

他按下這個按鈕做的 action 就只是送出 request 而已,

剩下的部分就是讓 saga 中的 generator 去管理,

而且經由這樣的拆分,我們發現接下來能夠實作 cancel

就是 saga 中的 compensating

這裡的 code 就請到 github 上面去看了 XD

總之我們得到了一樣的效果,但是更容易測試以及維護:

login flow

Abortable flow(compensating transaction)

前面有說到要實作取消這個功能,

在 promise 中是很困難的,因為 promise 沒有辦法 abort。

不過我們活用 generator 的,就有辦法很直觀的實作出這個功能來。

首先當然是先做出 cancel 這個 action,

以及讓 reducer 根據這個 action 作出對應的改變。

完成了之後,接下來就是 saga 的重頭戲了。

fork and cancel

首先我們要將原本的 loginFlow 拆分成兩部分,

第一部分是原本的 login 流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function* authorize({username, password}){
try {
const response = yield call(loginAPI, {
username,
password
})
yield put({
type: LOGIN_SUCCESS,
response
})
} catch (error) {
yield put({
type: LOGIN_ERROR,
error
})
}
}

第二部分則是取消 login:

1
2
3
4
5
export function* loginFlow(action) {
const task = yield fork(authorize,{username:action.username, password: action.password})
yield take(LOGIN_CANCEL)
yield cancel(task)
}

這裡我們看到兩個新的 effect,第一個是 fork,

語法基本上跟 call 相同,

不同的部分是 fork 跟我們在 git 上面的 fork 一樣會開一支 branch出來處理,

當 yield fork effect 之後,

就會自動開一條 branch 執行下去,這裡有個 @kuy 做的圖:

process

而如果我們在上述 task 完成之前,就接收到了 loginCancel 這個 action,

那所有在 task 裡面的動作就會被 abort 掉!

是不是覺得有 race condition 的概念在裡面,
沒錯,redux-saga也提供了 race 這個 effect

Test for cancelable flow

這裡一樣也測試以下幾件事情

  • 是否有 fork 一個新的 task

  • 是否能處理 cancel 這個 function

  • 拆分出來的 authorize 是否正常運作

首先當然是先看進入 loginFlow 之後有沒有 fork :

1
2
3
4
5
6
7
8
it('should fork to authorize', () => {
const expected = fork(authorize, {
username: 'denny',
password: '12345678'
})
const actual = iterator.next().value
assert.deepEqual(expected, actual)
})

接下來是是否能處裡 cancel,

這裡我們就需要用到 mock 了,

在最外層的地方從 redux-saga/utils 引用 createMockTask

1
2
3
4
5
6
7
8
9
10
11
12
const task = createMockTask()
it('should take cancel login action', () => {
const expected = take(LOGIN_CANCEL)
const actual = iterator.next(task).value
assert.deepEqual(expected, actual)
})
it('should cancel the login task', () => {
const expected = cancel(task)
const actual = iterator.next().value
assert.deepEqual(expected, actual)
})

這裡仍然是運用了 generator 的特性來做 mock,

因為我們再隔一個動作才能取消 task,

所以在這之前我們要先把 mock 起來的 task 丟進去。

最後則是確認原本的 authorize 流程還是能正常運作,

基本上只是把原本的 test case 丟進另一個 describe 的 block 而已,

詳情可以去看 repo 裡的 code。

Combine cancelable loginFlow

其實這裡蠻簡單的,

只是新增一個按鈕,按了會 dispatchcancelLogin這個action,

一切就結束了。

像是底下這個樣子:

cancel

Conclusion

結論就是我們現在終於將 saga pattern 應用在前端了,

每一個好的 UX 都會是一個 transaction,

而且比起原本的論文中,我們多了一些彈性,

可以選擇要不要加上 compensating transiction。

如此一來我們的非同步 action 變得更好測試,

而且也不用擔心在每次處理過度複雜的資料流時,

沒有依據可找了,因為我們都是在組合各種 effect 而已XD

參考資料