希望是最好懂的 Redux Saga

難得寫了一篇長文。

簡述

附註:saga 會需要一些 generator 的概念(不用很深,基礎就夠了),所以建議先把 快速入門 generator 看懂後再來看,不然會學的很有障礙。

這是第一次學 saga,相較於 thunk 來說確實是複雜了一些,不過只要照著教學一步一步自己練習,還是能慢慢理解他背後的邏輯的。

總之這篇文章的目的是希望能讓每個人都學會「怎麼用 saga」,也希望自己能把 saga 的概念弄得更清楚一些。

為了讓學習效果更佳,所以這篇會從零開始,也就是用 create-react-app 來開一個空的專案,先建立 redux 後再來來一步一步加上 saga,這樣才不會有一種「好像漏掉了什麼」的感覺存在。

順道一提,雖然等一下的範例會用到 redux,但是不會用到 react-redux、actionCreator、actionType 這些東西。我希望讓這個範例越簡潔越好,一方面省掉不必要的程式碼,另一方面也能更專心把重點放在 saga 上。

最後祝各位學習愉快,開始吧!

建立基本的 redux 環境

前置作業

首先,在 create-react-app 完以後,我希望資料夾結構長這樣就好:

1
2
3
4
5
6
7
8
9
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
└── src
└── index.js // 只留下這個

所以進到專案資料夾後先幫我執行這段,刪掉不必要的東西:

1
2
cd src
rm -rf App.css App.js App.test.js reportWebVitals.js index.css logo.svg setupTests.js

接著把 index.js 改成這樣:

1
2
3
4
5
6
7
8
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<div>Hello</div>
);

最後 npm run start,應該就會顯示 Hello 的畫面,這樣就 OK 了。

沒問題以後,記得順便把該裝的東西裝起來:

1
npm install redux redux-saga

正式作業

為了怕等一下混亂所以先說明,這個段落的檔案結構會長這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
└── src
├── Counter.js
├── index.js
└── store
├── index.js
├── reducer.js

接著要來正式加上 redux 環境,我們要做的就是一個經典的「Counter」。

雖然說是從零開始,不過 redux 的部分我不會講太多,只會大概講一下步驟跟附上程式碼。如果你完全不懂 redux 的話,建議你先參考 初探 Redux 再回來學 saga。

首先

1. 建立 Counter 元件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Counter.js
import React from "react";

export default function Counter () {
return (
<div>
<h2>Counter: 0</h2>
<div>
<button>+1</button>
<button>-1</button>
<button>+1 Async</button>
</div>
</div>
)
}

接著在 index.js 中引入:

1
2
3
4
5
6
7
8
9
10
import React from 'react';
import ReactDOM from 'react-dom/client';
import Counter from './Counter';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<div>
<Counter />
</div>
);

這個時候應該就有一個顯示 counter 值和按鈕的畫面了。

2. 加入 redux(建立 store 和 reducer)

src/store/reducer.js

1
2
3
4
5
6
7
8
9
10
export default function counterReducer (state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
}

src/store/index.js

1
2
3
4
import { createStore } from "redux"
import counterReducer from "./reducer"

export default createStore(counterReducer)

接著回到 src/index.js 把 store 拿進來用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import React from 'react';
import ReactDOM from 'react-dom/client';
import Counter from './Counter';
import store from "./store"

const root = ReactDOM.createRoot(document.getElementById('root'));

function renderRoot () {
root.render(
<div>
<Counter
value={store.getState()}
onIncrement={() => store.dispatch({ type: 'INCREMENT' })}
onDecrement={() => store.dispatch({ type: 'DECREMENT' })}
/>
</div>
);
}

renderRoot();

// store 改變時就重新執行 renderRoot
store.subscribe(renderRoot);

稍微解釋一下這段,因為我們沒有用 react-redux,所以改用 subscribe 的方式來做到「當 store 改變時重新 render」這件事情。

附註:如果你對 subscribe 的概念不太懂的話可以參考 用實作的方式來重新學習 Redux

另外 <Counter /> 的部分會傳給他三個 props:

  • values,把 store 中的 state 傳進去
  • onIncrement 用來 dispatch +1 的 action
  • onDecrement 用來 dispatch -1 的 action

最後把 <Counter /> 內部修改成這樣就完事了:

1
2
3
4
5
6
7
8
9
10
11
12
13
import React from "react";

export default function Counter ({ value, onIncrement, onDecrement }) {
return (
<div>
<h2>Counter: {value}</h2>
<div>
<button onClick={onIncrement}>+1</button>
<button onClick={onDecrement}>-1</button>
</div>
</div>
)
}

好,做到這邊 redux 的部分就完成了。

現在你的畫面應該要顯示 Counter 值,跟點下按鈕時會把值 +1 和 -1,跟 store 維持同步的狀態。如果有任何疑問的話可以到這邊來看原始碼

沒問題的話可以喝口水,下半場要開始了。

在加上 saga 之前,先認識一下 saga

如題,我想利用這段介紹一下 saga 是什麼?還有它跟其他的 middleware 有什麼差別?

saga 是 redux 的一個 middleware,如果你有學過 thunk 的話應該就對 middleware 這東西不陌生。

簡單來說,在 redux 的世界裡面如果我們想要改變 state,就必須透過 dispatch 來發出一個 action 這種方式來修改。

可是有一個問題來了,就是如果我的 action 是非同步操作,像是打 API 之類的話,我要怎麼用 dispatch 的方式來做?

你可能會想說我可以寫在 reducer 裡面啊,像這樣:

1
2
3
4
5
6
7
8
9
10
export default function myReducer (state = [], action) {
switch (action.type) {
case 'FETCH_REQUEST':
return fetch(...)
.then(res = res.json())
.then(data => state = data)
default:
return state
}
}

可是不要忘了 reducer 的原則是「pure function」,你不可能把這種「side effect」的事情放到裡面來做。

所以呢,middleware 最主要就是用來解決這個問題的,目前比較知名的幾個 middleware 有 redux-thunkredux-sagaredux-observable,但 redux-observable 我完全沒碰過所以就不多提了。

那 thunk 跟 saga 有什麼差別?我們直接用 code 來解釋吧。

如果要透過 thunk 來作非同步操作的話,你會這樣做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 最後會回傳一個 function
// thunk 的作用就是幫你執行 dispatch => .... 這一段
const action = () => dispatch => {
setTimeout(() => {
dispatch({
type: 'INCREMENT_ASNYC'
})
}, 1000)
}

function render() {
ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() => dispatch('INCREMENT')}
onDecrement={() => dispatch('DECREMENT')}
{/* thunk */}
onINcrementAsync={() => dispatch(action())} />,
document.getElementById('root')
)
}

而 saga 的話會這樣做:

1
2
3
4
5
6
7
8
9
10
11
function render() {
ReactDOM.render(
<Counter
value={store.getState()}
onIncrement={() => dispatch({type: 'INCREMENT'})}
onDecrement={() => dispatch({type: 'DECREMENT'})}
{/* saga */}
onINcrementAsync={() => dispatch({ type: 'INCREMENT_ASYNC' }} />,
document.getElementById('root')
)
}

注意到了嗎?在 thunk 裡我們 dispatch 的 action 是一個「function」,而 saga 裡面 action 幾乎就跟原本的一模一樣嘛,是我們最熟悉的「object」。

thunk 的好處是比較好學,但是測試不好做(因為邏輯都寫在 function 裡),saga 則是反過來,測試好做很多,但是學習曲線相對增高)

總之,希望這一段能讓你對 saga 有一些認識,還有他跟 thunk 的差別所在。

正式加入 saga

懶人包流程

在正式開始之前,先稍微看一下 saga 的運作流程:

saga-flow

附註:先不用管 takeEveryput 那些不知道是啥的東西,之後會再解釋。

  1. 在元件中 dispatch 一個 action
  2. watch saga 會接收到這個 action
  3. watch saga 會把這個 action 交給某個 handler 來處理
  4. handler 裡面會做一些事情,最後再 dispatch 另一個 action 到 reducer 來產生新的 state。

我知道現在看完應該還是霧煞煞,但你就先看個概念就好,可以等之後再回來看一遍就會理解了。

正式開始

延續剛剛的範例,我們現在要新增一個「一秒後才 +1 的按鈕」。

首先先新增一個 src/store/saga.js,跟 saga 相關的東西都會寫在這裡面,內容是這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { fork, put, take, delay } from "redux-saga/effects"

function* handlerIncrementAsync () {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}

export default function* rootSaga() {
while(true) {
yield take('INCREMENT_ASYNC')
yield fork(handlerIncrementAsync)
}
}

forkput 這些從 redux-saga/effects 拿出來的東西統稱為「Effects API」,基本上只會拿來跟 generator 搭配使用,先知道這些就好。

yield 後面只要接這些 Effects API 的話,就會等到「這個行為結束後」才會往下執行,以 handlerIncrementAsync 來看的話就是:

1
2
3
4
5
6
function* handlerIncrementAsync () {
// 等 1 秒
yield delay(1000)
// put 一個 action
yield put({ type: 'INCREMENT' })
}

put 可以直接想成是 dispatch 的意思,所以整段合起來就是「一秒後幫我 dispatch {type: 'INCREMENT'} 這個 action 出去」。

那會送到哪裡?其實就是送到 reducer 去。

接下來是我覺得在 saga 中很重要的觀念「watcher」,watcher 的用途就是用來幫你監聽:

  • 當元件 dispatch 什麼 action 時,我要交給哪個 handler 來處理
  • 當元件 dispatch 什麼 action 時,我要交給哪個 handler 來處理
  • 當元件 dispatch 什麼 action 時,我要交給哪個 handler 來處理

所以如果一個 action 是要交給 saga 來處理的話,那 action type 一定是對應到 saga 的 wacher,跟 reducer 一點關係也沒有。(詳細可以參考最下面的 地雷 段落)

因此 rootSaga 的意思是這樣:

1
2
3
4
5
6
7
8
export default function* rootSaga() {
while(true) {
// 當有元件發出 {type: 'INCREMENT_ASYNC'} 這個 action
yield take('INCREMENT_ASYNC')
// 就交給 handlerIncrementAsync 來處理
yield fork(handlerIncrementAsync)
}
}

至於為什麼要用 while(true) 來跑?這個跟 generator 的特性有關。簡單來說就是我希望這個監聽是「持續性」的,如果沒有 while(true),那執行完一次後就不會再繼續跑下去了(你可以之後自己拿掉看看就懂我的意思了)。

好,以上如果你有聽懂的話,saga 的核心觀念差不多就是這樣而已,剩下的只是把放到 store 而已。

最後來改一下 src/store/index.js 的內容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 把 applyMiddleware 拿出來,因為我們要用 middleware
import { createStore, applyMiddleware } from "redux"
// 用來建立 saga middleware 的東西
import createSagaMiddleWare from "redux-saga"
// 剛剛寫好的 watcher
import rootSaga from "./saga"
import counterReducer from "./reducer"

// 建立 instance
const sagaMiddleWare = createSagaMiddleWare()
// 把 middleware 放入 store(第二個參數)
export default createStore(counterReducer, applyMiddleware(sagaMiddleWare))
// 讓 saga 中的 watcher 跑起來
sagaMiddleWare.run(rootSaga)

附註:記得要先把 middleware 放進去初始化以後才可以跑起來,所以 sagaMiddleWare.run() 才會寫在最後面。

現在 saga 設定好了,store 也設定好了,還差什麼?只差把元件加上對應的 event handler 而已!馬上來加吧:

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
// src/index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import Counter from './Counter';
import store from "./store"

const root = ReactDOM.createRoot(document.getElementById('root'));

function renderRoot () {
root.render(
<div>
<Counter
value={store.getState()}
onIncrement={() => store.dispatch({ type: 'INCREMENT' })}
onDecrement={() => store.dispatch({ type: 'DECREMENT' })}
{/* 新增一個 props */}
onIncrementAsync={() => store.dispatch({ type: 'INCREMENT_ASYNC' })}
/>
</div>
);
}

renderRoot();

store.subscribe(renderRoot);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// src/Counter.js
import React from "react";

export default function Counter ({ value, onIncrement, onDecrement, onIncrementAsync }) {
return (
<div>
<h2>Counter: {value}</h2>
<div>
<button onClick={onIncrement}>+1</button>
<button onClick={onDecrement}>-1</button>
{/* 加上去 */}
<button onClick={onIncrementAsync}>+1 Async</button>
</div>
</div>
)
}

做到這邊後,就達成我們一開始要做的效果了,可以到這邊的範例來看。

最後恭喜你走到這邊,以上就是 saga 的基礎,如果都有理解的話我覺得對於 saga 就有一定的理解了。接下來要講的東西都算是額外補充或是延伸,但核心理念都還是跟剛剛學的是一樣的。

最後我也做了一個簡單的 saga 串 API 的練習,有興趣的話可以到去看看。

如果有多個 watcher 的話怎麼辦?

這時候可以改用 all 這個 Effects API 來處理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { put, delay, takeEvery, all } from "redux-saga/effects"

function* handlerIncrementAsync () {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}

function* handleDecrementAsync () {
yield delay(1000)
yield put({ type: 'DECREMENT' })
}


export default function* rootSaga() {
yield all([
yield takeEvery('INCREMENT_ASYNC', handlerIncrementAsync)
yield takeEvery('DECREMENT_ASYNC', handleDecrementAsync)
])
}

Promise.all() 有異曲同工之妙,不過比起這種寫法,你應該更常看到下面的寫法:

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
29
30
31
import { put, delay, takeEvery, all } from "redux-saga/effects"


function* handlerIncrementAsync () {
yield delay(1000)
yield put({ type: 'INCREMENT' })
}

function* handleDecrementAsync () {
yield delay(1000)
yield put({ type: 'DECREMENT' })
}


// watcher1
function* watchIncrementAsync () {
return yield takeEvery('INCREMENT_ASYNC', handlerIncrementAsync)
}

// watcher2
function* watchDecrementAsync () {
return yield takeEvery('DECREMENT_ASYNC', handleDecrementAsync)
}


export default function* rootSaga() {
yield all([
watchIncrementAsync(),
watchDecrementAsync()
])
}

仔細看就會發現是一樣的東西,只是用 function 來包裝而已。

takeEvey 跟 takeLatest 差在哪?

沿用剛剛例子,如果我是用 takeEvery 來做監聽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* watchIncrementAsync () {
return yield takeEvery('INCREMENT_ASYNC', handlerIncrementAsync)
}

function* watchDecrementAsync () {
return yield takeEvery('DECREMENT_ASYNC', handleDecrementAsync)
}


export default function* rootSaga() {
yield all([
watchIncrementAsync(),
watchDecrementAsync()
])
}

接著我一次點十下 +1 Async 的按鈕(按很快的那種),那就會在一秒後從 1, 2, 3, ...10,也就是說每一次的 dispatch 都會被處理。

takeLatest 就不一樣了,如果改成這樣:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function* watchIncrementAsync () {
return yield takeLatest('INCREMENT_ASYNC', handlerIncrementAsync)
}

function* watchDecrementAsync () {
return yield takeLatest('DECREMENT_ASYNC', handleDecrementAsync)
}


export default function* rootSaga() {
yield all([
watchIncrementAsync(),
watchDecrementAsync()
])
}

接著一樣一次點 10 下按鈕,最後 counter 的值會是 1,為什麼?因為只有最後一次的 dispatch 才會被處理,這個就是他們的差別。

所以 takeEverytakeLatest 還真是命名的有夠貼切 XD

一些學習時踩到的地雷

第一個

如果你跟我一樣是看 Redux Saga Beginner Tutorial 來學 saga 的話,也許就會碰到這個問題。

delay 這個東西在 redux-saga 的 v1 和 v1 以前有不同的引入方式,這個害我卡很久。

v1 前:

1
import { delay } from 'redux-saga'

v1 後:

1
import { delay } from 'redux-saga/effects'

另外,透過 redux-saga/effects 引入的 delay 不能透過 call(delay, 1000) 的方式來用,因為兩個的值不一樣。

一個是 function,一個是 effect creator,而call 的第一個參數只接受 function,所以才不可以這樣用。

詳細可以參考這篇討論

第二個

不要把 saga 監聽的 action 跟 reducer 中的 action 搞混,假設我的 reducer 長這樣:

1
2
3
4
5
6
7
8
export default function counterReducer(state = 0, action) {
switch (action.type) {
case 'INCREMENT':
return state + 1
default:
return state
}
}

接著我想用 saga 做一個延遲一秒後再加一的動作,那我一定是這樣寫:

1
2
3
4
5
6
export default function* rootSaga() {
while (true) {
yield take("INCREMENT_ASYNC")
yield fork(handleIncrementAsync)
}
}

而不是這樣寫:

1
2
3
4
5
6
7
export default function* rootSaga() {
while (true) {
// 跟 reducer 中的 action 同名
yield take("INCREMENT")
yield fork(handleIncrementAsync)
}
}

要知道用 saga 的邏輯是:

  1. 在元件中 dispatch 一個 action 給 saga watcher(不是 reducder)
  2. 觸發 saga watcher 後會丟給對應的 handler 處理
  3. handler 處理完以後,再幫我 dispatch 另一個 action 到 reducer 去。

所以這兩個 action 是完全不相干的東西,要交給 saga 處理的 action 絕對是對應到 watcher,不是 reducer,這個一定要弄清楚。

如果你真的不幸寫成一樣的名稱的話,那就會陷入無限迴圈(自己的實際經驗)。

參考資料

Ant Design-DatePicker 從 Callback 到 Promise 再到 Generator
Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×