哈?redux-thunk 不就是那個只有 14 行代碼的輪子嘛?我一行就能寫出來還要你來教我做事?
不錯,redux-thunk 是一個非常小的庫,不到 5 分鐘就能理解並造出來。但是今天我並不想從 “怎麼造” 這個角度來講這個輪子,而是想從 “爲什麼” 這個角度來聊一聊這個輪子的是怎麼出現的。
很多分析 redux-thunk 源碼的文章一般會說:如果 action 是函數的話就傳入 dispatch,在 action 函數裏面使用 dispatch
,如果action 不是函數的話就正常 dispatch(action)
。不過,我覺得這是從結果出發找造這個輪子的原因,並不能從需求層面解釋這個中間件到底解決了什麼問題。
本文希望從解決問題的角度來推導 redux-thunk 誕生的原因。
一個需求
首先,我們先把 redux-thunk 忘了,來看一下這個需求:
- 輸入框搜索用戶 Id,調用 getUserInfoById 來獲取用戶信息
- 展示對應用戶 id 和 name
首先,我們弄一個 store 存放 userInfo
。
// store.js
const initState = {
userInfo: {
id: 0,
name: '0號技師',
}
}
const reducer = (state = initState, action) => {
switch (action.type) {
case 'SET_USER':
return {...state, userInfo: action.payload} // 直接更新 userInfo
default:
return state
}
}
const store = createStore(reducer, initState)
然後使用 react-redux 提供的 Provider
向整個 App 注入數據:
// App.jsx
function App() {
return (
<Provider store={store}>
<UserInfo/>
</Provider>
)
}
最後一步,在 UserInfo
組件裏獲取並展示用戶信息。
// UserInfo.jsx
const UserInfo = () => {
const dispatch = useDispatch()
const userInfo = useSelector(state => state.userInfo)
// 業務組件狀態
const [loading, setLoading] = useState(false)
const [id, setId] = useState('')
// 根據 Id 獲取 userInfo
const fetchUserById = (id) => {
if (loading) return
return new Promise(resolve => {
setLoading(true)
setTimeout(() => {
const newUserInfo = {
id: id,
name: id + '號技師'
}
dispatch({type: 'SET_USER', payload: newUserInfo})
setLoading(false)
resolve(newUserInfo)
}, 1000)
})
}
return (
<div>
<div>
<input value={id} onChange={e => setId(e.target.value)}/>
<button onClick={() => fetchUserById(id)}>getUserInfo</button>
</div>
{
loading ? <div>加載中...</div> : (
<div>
<p>Id: {userInfo.id}</p>
<p>Name: {userInfo.name}</p>
</div>
)
}
</div>
)
}
上面代碼很簡單:在 input
輸入 id 號,點擊“getUserInfo”按鈕後觸發 fetchUserById
,1 秒後拿到最新的 userInfo
來更新 store 值,最後展示技師信息。
解耦
上面的代碼在很多業務裏非常常見,常見到我們根本不需要什麼 redux-thunk,redux-saga 來處理。不就是 fetch 數據,把數據放到 action.payload
,再 dispatch 這個 action 更新值嘛。所以很多人看到這些“框架”的時候都會覺得很奇怪:這些庫好像解決了一些問題,但好像又感覺沒做什麼大事情。
這麼寫有什麼問題呢?假如我想把 fetchUserById
抽到組件外面就很痛苦了,因爲整個 fetchUserById
完全依賴了 dispatch
函數。 有人可能會說了,我直接外層 import store.dispatch
來使用不就解除依賴了麼:
// store 一定爲單例
import store from './store'
const fetchUserById = (id) => {
if (loading) return
return new Promise(resolve => {
setTimeout(() => {
const newUserInfo = {
id: id,
name: id + '號技師'
}
store.dispatch({type: 'SET_USER', payload: newUserInfo})
resolve(newUserInfo)
}, 1000)
})
}
但是這樣會導致你的 store 必須是一個單例!單例就不好了麼?某些情況下如 SSR,mock store,測試 store 就需要同時存在多個 store 的情況。所以,單例 store 是不太推薦的。
另一個解耦方法:我們可以把 dispatch
作爲參數傳入,而不是直接使用,這樣就可以完成函數的解耦了:
// 根據 Id 獲取 userInfo
const fetchUserById = (dispatch, id) => {
if (loading) return
return new Promise(resolve => {
setTimeout(() => {
const newUserInfo = {
id: id,
name: id + '號技師'
}
dispatch({type: 'SET_USER', payload: newUserInfo})
resolve(newUserInfo)
}, 1000)
})
}
// UserInfo.jsx
const UserInfo = (props) => {
const dispatch = useDispatch()
const {userInfo, count} = useSelector(state => state)
const [loading, setLoading] = useState(false)
const [id, setId] = useState('')
const onClick = async () => {
setLoading(true)
await fetchUserById(id, dispatch)
setLoading(false)
}
return (
...
)
}
雖然上面的 fetchUserById
看起來還是有點智障,但是隻在使用的時候才傳入 dispatch,完全脫離了 dispatch 的依賴。
柯里化
每次執行 fetchUserById
都要傳一個 dispatch
進去,這不禁讓我們想到:能不能先在一個地方把 fetchUserById
初始化好,比如初始化成 fetchUserByIdWithDispatch
,讓它擁有了 dispatch
的能力,然後執行的時候直接使用 fetchUserByIdWithDispatch
函數呢?
使用閉包就解決了(也可以說將函數柯里化),所謂的柯里化也僅是多返回一個函數:
// 根據 Id 獲取 userInfo
const fetchUserById = (dispatch) => (id) => {
return new Promise(resolve => {
setTimeout(() => {
const newUserInfo = {
id: id,
name: id + '號技師'
}
dispatch({type: 'SET_USER', payload: newUserInfo})
resolve(newUserInfo)
}, 1000)
})
}
使用的時候把 dispatch
傳入生成新函數,相當於給 fetchUserById
“賦能”:
// UserInfo.jsx
const UserInfo = () => {
const dispatch = useDispatch()
const {userInfo, count} = useSelector(state => state)
const [loading, setLoading] = useState(false)
const [id, setId] = useState('')
const fetchUserByIdWithDispatch = fetchUserById(dispatch)
const onClick = async () => {
setLoading(true)
await fetchUserByIdWithDispatch(id)
setLoading(false)
}
return (
...
)
}
定義的 fetchUserById
有點類似工廠模式裏的工廠函數,由其生成的 fetchUserByIdWithDispatch
纔是我們真實想要的 “fetchUserById”。
這樣的 “函數式套娃” 在 redux 的很多輪子中都出現過,對造輪子有很大作用,希望大家可以對此有個印象。我自己對這樣處理一個形象的理解是:好比一個正在準備發射的火箭,每執行一次外層的函數時就像給這個火箭加一點能量,等執行到最後一個函數的時候整個火箭就以最快的速度噴射出去。
回到例子,這樣的函數聲明方式也不好,每次使用的時候都要用 dispatch 初始化一下,還是很麻煩。而且容易給人造成誤解:好好的 fetchUserById
不傳 id 而是傳一個 dispatch
函數來初始化。怕是會順着網線過來錘你。
把參數互換位置
我們理想中的 fetchUserById
應該是像這樣使用的:
fetchUserById(id)
把 dispatch 和 id 嘗試換一下看看效果如何:
// 根據 Id 獲取 userInfo
const fetchUserById = (id) => (dispatch) => {
return new Promise(resolve => {
setTimeout(() => {
const newUserInfo = {
id: id,
name: id + '號技師'
}
dispatch({type: 'SET_USER', payload: newUserInfo})
resolve(newUserInfo)
}, 1000)
})
}
組件裏的 onClick 就要這樣使用了:
const onClick = async () => {
setLoading(true)
const fetchDispatch = fetchUserById(id)
await fetchDispatch(dispatch)
setLoading(false)
}
雖然表面上可以 fetchUserById(id)
了,但是 fetchDispatch(dispatch)
也太醜了了。能不能把 “fetchUserById” 和 “dispatch” 反過來寫呢,變成這樣:
dispatch(fetchUserById(id))
這樣一來,所以用到 dispatch
的代碼都可以用下面這樣的函數來封裝了:
const fn = (...我的參數) => (dispatch) => {
// 用“我的參數”做一些事...
doSomthing(我的參數)
// dispatch 修改值
dispatch(...)
}
爲了下次懶得再一次解釋這樣的函數結構,乾脆用一個詞這概括它,就叫它 "thunk" 吧。
要實現上面的效果,我們需要更改 dispatch
函數內容,使其變成增強版的 dispatch
:入參爲函數時執行該函數的返回函數,同時傳入 dispatch
,如果爲普通 action 時直接 dispatch(action)
。
const originalDispatch = store.dispatch
const getState = store.getState
store.dispatch = (action) => {
if (typeof action === 'function') {
action(originalDispatch, getState)
} else {
originalDispatch(action)
}
}
直接賦值來增強 dispatch
是不太雅觀的,更優雅的方式是用 redux 提供了中間件的功能來增強 dispatch
函數。
中間件
可能很多人還不會寫 redux 的中間件。其實非常簡單,都是有套路的。首先,弄一個模板出來:
const thunkMiddleware = ({dispatch, getState}) => (next) => (action) => {
next(action) // 交給下一個中間件處理
}
上面相當於一個啥也不做的 "Hello World" 版中間件,然後根據我們剛剛的思路做出基礎版 redux-thunk 中間件:
const thunkMiddleware = ({dispatch, getState}) => (next) => (action) => {
if (typeof action === 'function') {
action(dispatch, getState) // 如果是函數,執行該函數
} else {
next(action) // 交給下一個中間件處理
}
}
然後在 store.js 裏用 applyMiddleware
加入中間件:
// store.js
const store = createStore(reducer, initState, applyMiddleware(thunkMiddleware))
刷新頁面會發現在執行 onClick
的時候,會發現根本沒有 loading!
const onClick = async () => {
setLoading(true)
await dispatch(fetchUserById(id))
setLoading(false)
}
這是因爲 fetchUserById
返回是個 Promise,而中間件裏沒有把它 return 出去,所以 setLoading(false)
並沒有等 await dispatch(fetchUserById(id))
的 Promise 回來就執行了。
爲了解決這個問題,只需在中間件里加一句 return
就好,並簡化一下代碼:
const thunkMiddleware = ({dispatch, getState}) => (next) => (action) => {
if (typeof action === 'function') {
return action(dispatch, getState)
}
return next(action)
}
可能有人會覺得 action(dispatch, getState)
裏爲什麼不傳 next
函數,而是傳入 dispatch
函數呢?畢竟 next 到最後就是 dispatch
了呀,這裏就不得不做 next
和 dispatch
這兩個函數的執行意義了:
- store.dispatch,也就是我們經常用到的 dispatch 函數,其實是通過所有中間件增強後的 dispatch,可以理解爲
completelyEnhancedDispatch
- next,函數簽名也是
(action) => action
,但是這是在走中間件時的函數,有點像增強到一半的 dispatch,可以理解爲partiallyEnhancedDispatch
對比如下:
函數 | 類型 | 增強程度 | 執行流程 | 意義 |
---|---|---|---|---|
dispatch | (action) => action |
完全增強 | 走完整個中間件流程,在最後調用原始的 dispatch(action)
|
開始整個分發的流程 |
next | (action) => action |
半增強 | next 前爲進入中間件部分,next 後爲返回中間件部分 | 交給下一個中間件處理 |
在 fetchUserById
函數裏的 dispatch
的工作是要分發 action,要這個 action 是要走完所有中間件流程的,而不是傳給下一個中間件處理,所以中間件裏傳入的參數爲 dispatch
函數而不是 next
函數。
withExtraArgs
上面看到我們“順手”把 getState
也作爲參數傳入 action 函數裏了,除了 dispatch 和 getState,開發者可能也可能想傳一些額外的參數進去,比如開發環境 env 啥的。
我們創建一個工廠函數 createThunkMiddleware
,然後把 extraArgs
傳入 action 第三個參數裏就可以了:
function createThunkMiddleware(extraArgs) {
return ({dispatch, getState}) => (next) => (action) => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgs)
}
return next(action)
}
}
const thunkMiddleware = createThunkMiddleware()
thunkMiddleware.withExtraArgs = createThunkMiddleware
export default thunkMiddleware
使用的時候完成參數傳遞:
// store.js
const store = createStore(
reducer,
initState,
applyMiddleware(thunkMiddleware.withExtraArgs('development'))
)
最後在 fetchUserById
裏獲取 "development"
的值:
// 根據 Id 獲取 userInfo
const fetchUserById = (id) => (dispatch, getState, env) => {
console.log('當前環境', env)
return new Promise(resolve => {
setTimeout(() => {
const newUserInfo = {
id: id,
name: id + '號技師'
}
const state = getState()
dispatch({type: 'SET_USER', payload: newUserInfo})
dispatch({type: 'SET_COUNT', payload: state.count + 1})
resolve(newUserInfo)
}, 1000)
})
}
覆盤
到此我們終於實現了 redux-thunk 這個庫的功能了。再來複盤一下整個過程是怎樣的:
- 我們需要完成獲取信息,並用
dispatch
修改 store 數據的需求,按理說啥事沒有 - 但是發現在組件裏這麼寫會依賴
dispatch
函數,所以把dispatch
放到參數上 - 又發現每次執行的時候都要傳入
dispatch
函數,很麻煩,所以把dispatch
作爲第一個參數,並寫出(dispatch) => (id) => {...}
這樣的函數結構,用dispatch
初始化後可以到處使用了 - 發現每次都要初始化還是很麻煩,而且容易被誤導,所以我們考慮使用
(id) => (dispatch) => {...}
的函數結構,但是會出現fetchUserById(id)(dispatch)
這樣的結構 - 我們希望將整個結構反過來變成這樣:
dispatch(fetchUserById(id))
,所以想到了要改寫dispatch
函數 - 發現直接賦值是個很笨的行爲,比較高級的是使用中間件來改寫
dispatch
函數 - 最後,我們做了一箇中間件出來,就叫做 redux-thunk
總結
最後來回答一些我在 redux 社區裏看到的一些問題。
redux-thunk 到底解決了什麼問題?
會發現 redux-thunk 並沒有解決什麼實際問題,只是提供了一種寫代碼的 “thunk 套路”,然後在 dispatch 的時候自動 “解析” 了這樣的套路。
那有沒有別的 pattern 呢?有的,再比如你寫成 Promise 的形式,然後 dispach(acitonPromise)
,然後自己在中間件裏解析這個 Promise:
export default function promiseMiddleware({ dispatch }) {
return next => action => {
if (!isFSA(action)) {
return isPromise(action) ? action.then(dispatch) : next(action);
}
return isPromise(action.payload)
? action.payload
.then(result => dispatch({ ...action, payload: result }))
.catch(error => {
dispatch({ ...action, payload: error, error: true });
return Promise.reject(error);
})
: next(action);
};
}
寫好了吧?再發個 npm 包吧?OK,一個月下載量 7 萬的 redux-promise 中間件就實現了。啊?這麼簡單的代碼都值 7 萬?不行,我也要自己編 pattern,把 Promise 改成 generator:dispatch(actionGenerator)
不就又一個 pattern 了,但是這個已經被 redux-saga 註冊專利了。呃,那用 RxJs?但是被 redux-observable 實現了。
基本上能想到的 pattern 都被開發得差不多了,目前來說,redux-thunk, redux-saga 以及 redux-loop 是比較常用的 “pattern 解析器”,他們自己都提供了一套屬於自己的 pattern,讓開發者在自己的框架裏隨意 dispatch。
需要注意的是,redux-thunk 和後面兩者其實並不是一個等級的庫,後面兩都除了提供 pattern 的 “翻譯” 功能之外還有很多如 error handling 這樣的特性,這裏不展開說了。
dispatch 到底是異步的還是同步的
剛開始學習的人看到 await dispatch(getUserById(id))
就會覺得加了中間件後 dispatch
是個異步函數,但是 redux 文檔說了 dispatch
是同步的,感覺很蒙逼。
解析一下無論加了多少箇中間件,最原始的 dispatch
函數一定是個同步函數。之所以可以 await
是因爲 getUserById
返回的函數是異步的,當 dispatch(getUserById(id))
時其實是執行了 getUserById
的返回函數,此時 dispatch
確實是異步的。但是,對於普通的 dispatch({type: 'SET_USER', payload: ...})
是同步的。
要不要使用 redux-thunk
如果你在第 1 步的時候就覺得依不依賴 dispatch
對我都沒什麼影響,在組件裏直接用 dispatch
也很方便呀。那完全不用管理什麼 thunk,saga 的,安心擼頁面就可以了。
redux-thunk 說白了也只是提供一種代碼書寫的 pattern,對提取公共代碼是有幫助的。但是也不要濫用,過度使用 thunk,很容易導致過度設計。
比如,就剛剛這個需求,只是拿個用戶信息設置一下,這麼點代碼放在組件裏一點問都沒有,還談不上優化。就算這個代碼被用了 2 ~ 3 次了,我覺得還是可以不用這麼快來優化。除非出現 5 ~ 7 次的重複了並且代碼量很大了,那麼可以考慮提取爲公共函數。
有時過度設計會造成嚴重的反噬,出現一改就崩的局面。而重複冗餘的代碼卻可以在需求變化多端的項目中實現增量優化。優化與重複總是在天平的左右,做項目時應該保持一種天然平衡,而不是走向極端。