今天的主題是在Redux中處理副作用,我們要使用的是一個稱為middleware
(中介軟體)的技巧來作,這在Redux裡就有內建這個設計了。以目前來說,middleware
(中介軟體)是最簡單的一種解決在Redux中副作用問題的方式,也有很多這種middleware
套件,不過在這個例子中並沒有使用像Redux作者開發的redux-thunk套件,我把它放在補充說明裡。
這個程式最後的呈現結果,就像下面的動態圖片這樣:
註: 本文章同步放置於Github庫的這裡,所有的程式碼也在裡面。
一樣是繼續使用上一章接著用下來的ES6的樣版文件webpack-es6-startkit。
如同之前的React這篇中,我們一樣使用json-server在自己的電腦上另外啟動一個伺服器,作為獲取資料的伺服器之用,items.json是已經有一些資料的範例資料檔案,也有附在github的原始碼位置中,用下面的指令來啟動json-server:
json-server --watch --port 5555 items.json
如果正確啟動json-server,你應該可以在瀏覽器網址上輸入http://localhost:5555/items/
,正常看到這幾筆範例資料。
今天的範例中,實際上對之前的MyComponent
元件有進行改造的作業,把它切成兩個小元件,分別是用於輸入項目的ItemInput
與顯示每個項目的ItemList
,然後在MyComponent
元件只是作組合的,這部份主要是為了要展示Redux store在進行綁定時,用不同的傳入參數的彈性作法。
index.html檔案沒變,就略過不說了。
一樣我們先從reducer來看,下面這是新的reducer程式碼:
reducer.js
import { combineReducers } from 'redux'
import { ADD_ITEM, DEL_ITEM, INIT_ITEMS } from './actionTypes'
function items(state = [], action) {
switch (action.type) {
case ADD_ITEM:
{
return [{
id: action.id,
text: action.text,
}, ...state]
}
case DEL_ITEM:
{
return state.filter(item => item.id !== action.id)
}
case INIT_ITEMS:
{
return [...action.items]
}
default:
return state
}
}
const itemApp = combineReducers({ items })
export default itemApp
這個reducer與前一個的範例的差異只有多一個INIT_ITEMS
的Action(動作)情況,它是要在從伺服器上獲取資料後,初始化store中的items記錄用的,這個名稱可能不是太好,或許取名叫LOAD_ITEMS
也可以。
另一個明顯的改進作法,是把所有的Action Type(動作類型),全部都轉為常數字串值,也就是變為常數,但是裡面指定的是原來的字串值。這個常數的定義放在actionTypes.js這檔案,程式碼其實簡單到不行:
actionTypes.js
export const ADD_ITEM = 'ADD_ITEM'
export const DEL_ITEM = 'DEL_ITEM'
export const FETCH_ITEMS = 'FETCH_ITEMS'
export const INIT_ITEMS = 'INIT_ITEMS'
你可能會問,這樣作是有什麼用處?或是這樣作是在搞笑嗎?
會這樣作是有原因的,而且它是一種改進的作法,我在本章的下面有附它的說明,先簡單的說一下主要的原因:
因為單純的字串值是不會作任何檢查的,以簡單的例子來說明,假設現在有一個動作類型應該是
'ADD_TODOITEM'
這個字串值,在動作生成器(Action Creator)或reducer中,打錯這個字串像'ADD_TODO_ITEM'
,當你在開發與執行時,系統都不會有錯誤的訊息,只是不會如預期的執行結果。但是如果是個常數則會檢查,錯了也有錯誤訊息。
接著來看建立store的部份,你應該要養成習慣了,我們從一開始的Redux範例到這一章,其實我的解說順序就應該是撰寫程式碼的大致上的順序,你如果要看一支用Redux寫的程式,順序也是這樣來看,整體的思維才會清楚。架構型的函式庫或框架都有基本的套用或架構起來的步驟,如果你不按這個次序,那會理解得零亂組合不起來。
建立store的部份在index.js中,它也是React應用最上層的元件,程式碼如下:
index.js
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider } from 'react-redux'
import { createStore, applyMiddleware } from 'redux'
import itemApp from './reducer'
import MyComponent from './app'
import fetchMiddleware from './fetchmiddleware'
// @Store
//
// store = createStore(reducer)
// 使用redux dev tools
// 如果要正常使用是使用 const store = createStore(dealItem, applyMiddleware(fetchMiddleware))
const store = createStore(itemApp,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(),
applyMiddleware(fetchMiddleware))
ReactDOM.render(
<Provider store={store}>
<MyComponent />
</Provider>,
document.getElementById('root'))
index.js
的更動部份有一個,第一個是它在匯入時,多從redux匯入applyMiddleware方法,以及匯入一個名稱為fetchmiddleware
的模組,這個檔案是你自己要提供的。
第二個是它在建立store時,多加了一個傳入參數applyMiddleware(fetchMiddleware)
,也就是說middleware(中介軟體)的套用,是在store一開始就需要套用的,這一個你一定要記得。
fetchMiddleware的程式碼也很簡單,像下面這樣:
fetchMiddleware.js
import { FETCH_ITEMS } from './actionTypes'
const fetchMiddleware = (store: any) => (next: any) => (action: Object) => {
if (action.type !== FETCH_ITEMS) return next(action)
// 這是獲取伺服器上的資料的fetch語法,
// 注意最後會執行action.cb(json, store.dispatch)
// 也就是onFecthData中的cb屬性
fetch('http://localhost:5555/items/')
.then(response => response.json())
.then(json => action.cb(json, store.dispatch))
.catch((err) => { throw new Error(err.message) })
}
export default fetchMiddleware
因為middleware(中介軟體)其實有基本的語法樣式,本章的後面有附說明,其實在簡單的情況下,你也不需要用什麼額外的套件,自己寫可能快一點,基本的樣式像下面這樣:
const customMiddleware = store => next => action => {
if(action.type !== 'custom') return next(action)
// 這裡作你要作的事
}
這看起來可怕的三個肥箭頭,相當於下面這樣的寫法的簡寫法,它只是一種巢狀(內部)函式的寫法,是是一種閉包結構的實現,至於為什麼要這樣作,這與middleware的設計有關:
const customMiddleware = function(store) {
return function(next) {
return function(action) {
if(action.type !== 'custom') return next(action)
action(next, store.getState) // invoke the action
}
}
}
middleware(中介軟體)的執行期間是介於dispatch(action)
與最後執行reducer
之間,而且可以執行多個middleware。也就是說,在最後真正把Action送到reducer前,middleware會先執行,處理完成再作送到reducer去,所以我們的程式在要從伺服器中獲取資料時,會像下面這樣的流程:
FETCH_ITEMS
的動作,也就是執行onFecthData
這個Action Creator(動作產生器)onFecthData
產生物件,Redux先經過middleware,fetchMiddleware裡有對FETCH_ITEMS
的動作的處理函式,進行處理INIT_ITEMS
的Action Creator(動作產生器)onInitData
onInitData
後產生物件,Redux最後送到reducer所以接著我們來看actons.js這個檔案,這是所有Action Creators(動作產生器)的集中的地方,程式碼如下:
actons.js
import { ADD_ITEM, DEL_ITEM, FETCH_ITEMS, INIT_ITEMS } from './actionTypes'
// onItemAdd處理產生'ADD_ITEM'的動作物件,注意傳入參數是payload
export const onItemAdd = (text: string) => (
{ type: ADD_ITEM,
id: +new Date(),
text,
}
)
// onItemAdd處理產生'ADD_ITEM'的動作物件,注意傳入參數是id
export const onItemDel = (id: number) => ({ type: DEL_ITEM, id })
// onInitData是初始化用的,這是準備要讓reducer重新載入store裡面的items值
export const onInitData = (items: Array<Object>) => ({ type: INIT_ITEMS, items })
// onFecthData是副作用函式,真正的執行程式碼在fetchMiddleware裡,執行後會到這個函式來
export const onFecthData = () => (
{
type: FETCH_ITEMS,
cb: (response: Array<Object>, dispatch: Function) => dispatch(onInitData(response)),
}
)
我把id值由時間物件產生這句+new Date()
寫在這裡,Action Creator在Redux實際上並沒有要求一定要是純粹函式,只是如果你用了異步執行但沒有對應的middleware時會出現中斷錯誤,下面有更多說明。
這裡最特別的是onFecthData
這個函式,它的payload
是一個函式,不像其他的都是一個資料性質的屬性,這個cb
的意思是callback(回調)函式的意思,意思是在fetchMiddleware中為FETCH_ITEMS
動作處理完後,會回來呼叫這個cb函式,而且會帶處理完的值第一個傳入參數給它,第二個值是dispatch函式,這剛好就是對照fetchMiddleware中fetch的語句中的這一行:
.then(json => action.cb(json, store.dispatch))
最後把json的資料作物件化後,用action.cb來執行。然後再呼叫到dispatch(onInitData(response))
,最後再到reducer去把資料整個重新載入。這就是整個執行的過程式。
再來看React元件的部份,所有的React元件我都改為函式的寫法,因為都用不到生命週期方法,也不需要內部的state特性。app.js中是我們之前的MyComponent
元件,程式碼如下:
app.js
import React from 'react'
import ItemList from './ItemList'
import ItemInput from './ItemInput'
const MyComponent = () => (
<div>
<ItemInput />
<ItemList />
</div>
)
export default MyComponent
MyComponent
只是用來組合另外兩個元件用的,實際上它沒任何其他的功能,說實在可以在最上層組合元件就行了,寫出來只是讓你比較一下而已。
下面是ItemInput元件的程式碼:
ItemInput.js
import React from 'react'
import { connect } from 'react-redux'
import * as actionCreators from './action'
const ItemInput = (props: any) => {
// 給Flow檢查使用的
let input: any = null
// 解構賦值,把props裡的方法指定出來
const { onItemAdd, onFecthData } = props
return (
<form onSubmit={(e: Event) => {
e.preventDefault()
if (!input.value.trim()) {
return
}
// 執行Action Creator中的onItemAdd,
// 相當於store.dispatch()執行ADD_ITEM的action
onItemAdd(input.value)
input.value = ''
}}>
<input ref={(node: any) => { input = node }} />
<button type="submit">新增項目</button>
<button onClick={() => onFecthData()} >從伺服器獲取資料</button>
</form>
)
}
// 連接Redux store
// 只需要綁定actionCreators中的方法即可,store中的項目值不需要
export default connect(null, actionCreators)(ItemInput)
這ItemInput
元件,裡面用了之前在React篇的TodoApp的改造作法,如果有不懂之處可以看那篇的說明。
最特別的是新增項目不需要綁定Redux store的items裡來,因為ItemInput
元件根本不需要知道現在的store裡的items值是怎麼樣的,只要管資料輸入然後發送新增項目的動作就可以了。除非你想要在輸入時作檢查,例如看看有沒有重覆標題的項目之類的,不然是不需要綁定store裡的items值到這個元件的props屬性上的。
也就是只要呼叫export default connect(null, actionCreators)(ItemInput)
就行了,當然你也可以寫得更細些,例如我只需要actionCreators中的,與這個元件中需要用的方法,不需要全部都綁進props屬性來,這也是可以作得到的,react-redux中可以這樣設定,你可以再參考API文件中的說明。
另一個ItemList
元件則是呈現項目列表,它裡面會用到幫每個項目加上勾選盒,然後發送onItemDel的動作,程式碼如下:
import React from 'react'
import { connect } from 'react-redux'
import * as actionCreators from './action'
const ItemList = (props: any) => {
// 解構賦值,提取props中的items項目值,
// 以及onItemDel方法
const { items, onItemDel } = props
return (
<p>
{
items.map(item => (
<li key={item.id}>
<input
type="checkbox"
id={item.id}
onClick={ () => onItemDel(item.id) }
/>
{item.text}
</li>
))
}
</p>
)
}
// 準備綁定用的mapStateToProps方法,
// 將store中的items屬性綁到這個元件的props.items屬性上
const mapStateToProps = store => ({ items: store.items })
// 連接Redux store
export default connect(mapStateToProps, actionCreators)(ItemList)
ItemList
就有綁定Redux store中的items值到元件的props上,不然怎麼知道要輸出什麼項目呢。
這分成兩個元件來寫,主要還是給你一些思考,不是每個元件都一定要綁定Redux store中的狀態值到元件的props上,也不是每個元件都需要所有的方法,而且在所有React應用的裡的所有下層(內層)元件,通通都可以作連接Redux store的動作,不過有些實在太小,功能只有顯示用的元件,可能也不需要與Redux store連接。一切的連接設定由你決定。
以上就是所有程式碼的解說。
在webpack-es6-startkit裡因為沒有先附eslint-plugin-react,所以ESLint檢查工具會無法正確檢查React中的JSX語法,這套件可以另外再裝,指令如下:
npm i eslint-plugin-react --save-dev
而.eslintrc
的設定可再改成下面這樣,這個套件有內建的一些建議撰寫風格規則:S
{
"parser": "babel-eslint",
"extends": ["airbnb-base", "plugin:react/recommended"],
"rules": {
"semi": ["error", "never"]
},
"parserOptions": {
"ecmaFeatures": {
"jsx": true,
"experimentalObjectRestSpread": true
}
},
"env": {
"browser": true,
"node": true
},
"plugins": [
"import",
"react"
]
}
改完記得重開你的編輯工具,這樣才會正常載入這個外掛。
在Redux篇的第一章中,最後我加了一個註解,原本是像下面這樣:
註: Action Creator在Redux中也是要求是個純粹函式。
後來我改成了這樣:
註: Action Creator在Redux中並沒有要求一定要是個純粹函式,只是不建議在裡面直接執行有副作用的函式,之後的章節會有說明。請參考這篇在stackoverflow的Reduce作者的回答。
那到底是可不可以在Action Creators(動作產生器)使用有副作用的不純粹函式?
如果要以我的看法,我認為是不行,原因是你用了異步執行或在某個Action Creator中呼叫另一個時,必會產生錯誤。你可以試試看在我們的範例中的Action Creators(動作產生器)執行一個異步的呼叫,例如setTimeout或Ajax之類的,你會得到一個中斷執行的錯誤訊息:
Uncaught Error: Actions must be plain objects. Use custom middleware for async actions.
那問題來了,Redux作者在網路上的回答說沒要求。而Redux的官網文件,以前是說Action Creator要是個純粹函式,現在的官方文件中拿掉了。
這混亂的定義情況是怎麼回事?可以這樣寫了又改的嗎?
以前的官網文件會講"Action Creator要是個純粹函式",這在Redux的開發中的議題討論中有一則,這被認為是個"無惡意的謊言"(white lies)。
我認為原因在於middleware
,用了像redux-thunk後,你可以在Action Creators(動作產生器)隨你高興回傳異步執行的函式,不用這種middleware
(中介軟體)的就不行。middleware
的設計打破了Action Creator原本的限制,為了怕學習的開發者誤解,所以文件裡並沒有說Action Creator一定要純粹函式,但是在程式碼裡卻擋了一個中斷錯誤,當你沒用專門處理異步執行的middleware
,阻止開發者直接在Action Creator使用異步執行的動作。
以我的看法,Action Creator是個灰色地帶,因為至今在Redux中有許多的解決異步執行的方式,像react-thunk這樣的middleware
,它擴充了Action Creator中撰寫異步執行的能力,但執行是在react-thunk上,這種作法可能容易學習與使用,但有一個很大的壞處,就是不好測試。
註: 雖然在之前介紹副作用與純粹函式的章節中,我們有提到一些呼叫外部API(console.log)、時間(Date())、隨機(Math.random)也屬於有副作用的呼叫,但以等級來區分它們只算是"輕度"或"微量"的副作用,這些在reducer或Action Creators能不能用?答案是可以用但最好不要用。以真正在討論副作用的主題來說,異步執行才是"中度"或"一般"等級的副作用,我們談級副作用通常是指這個等級的。當然也有"重度"等級的副作用,那是另一個層次的特殊應用情況討論。
ActionTypes會先轉為常數定義,然後在Actoion Creater與reducer中再使用,這是現在通常的作法,像下面這樣的程式碼:
./actionTypes.js
export const ADD_TODO = 'ADD_TODO';
export const DELETE_TODO = 'DELETE_TODO';
export const EDIT_TODO = 'EDIT_TODO';
./actions.js
import { ADD_TODO } from './actionTypes';
export function addTodo(text) {
return { type: ADD_TODO, text };
}
./reducer.js
import { ADD_TODO } from './actionTypes';
export default (state = [], action) => {
switch (action.type) {
case ADD_TODO:
return [
...state,
{
text: action.text,
completed: false
}
];
default:
return state
}
};
根據dan_abramov在這個issue中的說明,作為常數的好處如下:
實際上在使用時,因為字串值是不會作任何檢查的,以簡單的例子來說明,假設現在有一個動作類型應該是'ADD_TODOITEM'
這個字串值,在動作生成器(Action Creator)或reducer中,打錯這個字串像'ADD_TODO_ITEM'
,當你在開發與執行時,系統都不會有錯誤的訊息,只是不會如預期的執行結果。所以就算這個字串並是你要的,實際上也沒這個動作類型存在,你也要多花時間對照才找得出來錯在什麼地方。不過,如果你是用常數值就不是這樣了,檢查工具或執行時會直接出現警告訊息。這樣你應該很容易的理解,為什麼要多花時間作這層工夫。
middleware
(中介軟體)是一種中介的應用或處理函式,redux在執行流程中可以插入這種函式,讓在最後輸出前進行額外的執行。middleware
的執行期間是介於dispatch(action)與執行reducer之間,而且可以執行多個middleware。middleware
的語法是函式連鎖的語法(實際上應該稱為部份函式partial function),是一種閉包(Closure)的結構。
在這篇簡易教學中有講解如何寫出一個middleware,本章的fetchMiddleware就是參考這篇教學的。
middleware的基本語法樣式,if語句只是檢查是不是需要執行這個middleware的action而已:
const customMiddleware = store => next => action => {
if(action.type !== 'custom') return next(action)
//這裡作你要作的事
}
如何套用在redux上,注意是套在createStore
方法中作為第二參數,而且需要加上applyMiddleware
方法:
import { createStore, applyMiddleware, } from 'redux'
import reducer from './reducer'
import customMiddleware from './customMiddleware'
const store = createStore(
reducer,
applyMiddleware(customMiddleware)
)
使用上面的寫法寫出一個fetch資料的fetchMiddleware,程式碼如下(要先安裝whatwg-fetch):
fetchMiddleware.js
import 'whatwg-fetch'
const fetchMiddleware = store => next => action => {
if(action.type !== 'FETCH_ITEMS') return next(action)
fetch('http://localhost:3000/sample.json')
.then(response => response.json())
.then(json => action.cb(json, store.dispatch))
.catch((err) => { throw new Error(err.message) })
}
export default fetchMiddleware
重點在於最後的action.cb(json, store.dispatch)
,可以讓對應的Action去呼叫另一個作為處理的Action,也就是INIT_ITEMS
這個Action Creater寫法。
action.js
export const onFecthData = () => ({ type: 'FETCH_ITEMS', cb: (response, dispatch) => dispatch(onInitData(response))})
export const onInitData = (items) => { return ( {type: 'INIT_ITEMS', items} ) }
在reducer中也有對應的處理程式碼,就是把從伺服器中的資料,整個取代原有的資料而已,這個語法很簡單:
reducer.js
case 'INIT_ITEMS':
{
return [...action.items]
}
thunk的中文翻譯是: 形式轉換
Thunk
是一種包裝用的函式,是一種用於惰性求值(lazy/delay evaluation)的函式,也可以稱它為臨時函式。JavaScript中原本就有closure(閉包)的特性,將內部函式回傳後,仍然會記錄外部函式當時傳入的參數值,這個特性可以很好的應用在臨時函式中,而且,函式本身也是一種可作為值傳遞的或保存的資料類型。
// 直接求值 1 + 2
// x === 3
let x = 1 + 2;
// 延時求值 1 + 2
// foo 可以之後再呼叫來執行計算
// foo 就是thunk!
let foo = () => 1 + 2;
redux-thunk讓你可以在Action Creator中回傳函式,在Redux限制中原本是只能回傳一般的物件(plain object)。可以用於延時(delay)dispatch一個動作,或是只在某些情況下才作dispatch。內部函式可以接收到store的dispatch方法與getState,作為它的傳入參數。redux-thunk是Redux作者所創造的專案,也就是最基本用於處理異步動作的套件,它在網路上教學也最多。
一個小小的實驗是用在之前的加入項目的action creator,把它改成延時執行的,在3秒後才會加入如下:
export const onItemAdd = text => ({ type: 'ADD_ITEM', text })
加入一個onItemAddAsync在action.js中,然後改用這個action來加入項目,程式碼如下:
export const onItemAddAsync = text => {
return dispatch => {
setTimeout(() => {
dispatch(onItemAdd(text))
}, 3000);
}
}
在ItemInput.js
中改用onItemAddAsync
應該會明顯感到加入項目時會變慢加入。
另一個可以獲取的參數是getState函式,這個函式可以得到目前store中的狀態值,這給了一些靈活應用的情況,例如在加入項目時,如果目前已經太多了,就不給加入新的項目,範例如下:
export const onItemAddAsync2 = text => {
return (dispatch, getState) => {
if(getState().items.length < 7){
setTimeout(() => {
dispatch(onItemAdd(text))
}, 1000)
}else{
console.log('too many items, delete some old items and retry');
}
};
}
redux-thunk相當方便地自動會進行在Action Creator的函式,不論是異步或同步的函式都可以在這裡面進行處理。
先說結論,副作用的處理在Redux一直是個話題,也有很多解決的方式,各自有優點也有缺點。
Redux的許多核心的設計由Elm語言啟發而來,那Elm是如何處理副作用或異步執行的?
答案可能會令你吃驚,以基本的設計來說Elm是在reducer中處理的,啊?reducer不是純粹函式,怎麼能作異步執行或有副作用的執行?
簡單來說,Elm是直接擁抱異步執行動作與副作用。有一個redux-loop專案,是從elm-effects(現已內建在Elm中)與Elm架構的移植過來的工具函式庫,用的是把異步執行直接寫在reducer中,主要是它讓reducer也沒直接處理異步,只是記錄要作的事情然後轉交給別的方式去處理,實際上並沒有破壞純粹函式的設計,所以這樣也行就是。
redux-loop並不是middleware,它算是增強或改進Redux的本質結構的一種工具套件,它有一些取代原有Redux方法,你要使用它需要改用裡面的combineReducers
函式,與在建立store時要多傳入install函式,目前來說它仍然是一個發展中的專案。
那麼redux-loop就有可能是最佳的解決方案了嗎?也不盡然,Elm語言自然有它設計上與JavaScript語言的不同之處,也不是那麼容易就能完全發揮其中的這樣的設計。至少現在看起來redux-loop並沒有那麼流行(Github上有900多個星星),Redux中還有其他的較為流行的類似目的專案。
目前Redux中的相同目的的專案,大概有以下幾個,我把Github的星星數目也列出來,作為一個使用者數量的指標:
redux-saga應該是目前最流行的專案,有自己一套完整架構的middleware,文件也有翻成繁體中文在這裡與簡體中文在這裡。但因為要使用它需要先對ES6的Generators特性有一定的理解,才能夠靈活的運用它,算是另一個學習的門檻。
以上這些就是目前大概能找得到的middleware與移植Elm的redux-loop的幾個解決方案,其中最流行的是redux-saga,它是一個具有高度可測試性的middleware解決方案,這也是它會受到歡迎的其中一大原因。在stackoverflow上的這篇回答,與這篇的回答中,可以理解它與redux-thunk設計的不同之處。
Redux作者在2016年3月時,發了一個議題在Github上: Reducer Composition with Effects in JavaScript
這篇文章就是一個公開討論目前的使用middleware來處理副作用,仍然有一些不夠完美的情況。當然我們都不是很深入應用這些框架的開發者,只能從這些討論中理解要如何進行未來的改進。不過這算是一個進階的議題,或許2017年會有新的改進作法也說不定。