这是篇长文,你可以直接跳到你想看的地方就好
或是直接在 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

参考数据