造一個 redux-thunk 輪子

源碼倉庫:https://github.com/Haixiang6123/my-redux-thunk

哈?redux-thunk 不就是那個只有 14 行代碼的輪子嘛?我一行就能寫出來還要你來教我做事?

不錯,redux-thunk 是一個非常小的庫,不到 5 分鐘就能理解並造出來。但是今天我並不想從 “怎麼造” 這個角度來講這個輪子,而是想從 “爲什麼” 這個角度來聊一聊這個輪子的是怎麼出現的。

很多分析 redux-thunk 源碼的文章一般會說:如果 action 是函數的話就傳入 dispatch,在 action 函數裏面使用 dispatch,如果action 不是函數的話就正常 dispatch(action)。不過,我覺得這是從結果出發找造這個輪子的原因,並不能從需求層面解釋這個中間件到底解決了什麼問題。

本文希望從解決問題的角度來推導 redux-thunk 誕生的原因。

一個需求

首先,我們先把 redux-thunk 忘了,來看一下這個需求:

  1. 輸入框搜索用戶 Id,調用 getUserInfoById 來獲取用戶信息
  2. 展示對應用戶 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 了呀,這裏就不得不做 nextdispatch 這兩個函數的執行意義了:

  • 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 這個庫的功能了。再來複盤一下整個過程是怎樣的:

  1. 我們需要完成獲取信息,並用 dispatch 修改 store 數據的需求,按理說啥事沒有
  2. 但是發現在組件裏這麼寫會依賴 dispatch 函數,所以把 dispatch 放到參數上
  3. 又發現每次執行的時候都要傳入 dispatch 函數,很麻煩,所以把 dispatch 作爲第一個參數,並寫出 (dispatch) => (id) => {...} 這樣的函數結構,用 dispatch 初始化後可以到處使用了
  4. 發現每次都要初始化還是很麻煩,而且容易被誤導,所以我們考慮使用 (id) => (dispatch) => {...} 的函數結構,但是會出現 fetchUserById(id)(dispatch) 這樣的結構
  5. 我們希望將整個結構反過來變成這樣:dispatch(fetchUserById(id)),所以想到了要改寫 dispatch 函數
  6. 發現直接賦值是個很笨的行爲,比較高級的是使用中間件來改寫 dispatch 函數
  7. 最後,我們做了一箇中間件出來,就叫做 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 次的重複了並且代碼量很大了,那麼可以考慮提取爲公共函數。

有時過度設計會造成嚴重的反噬,出現一改就崩的局面。而重複冗餘的代碼卻可以在需求變化多端的項目中實現增量優化。優化與重複總是在天平的左右,做項目時應該保持一種天然平衡,而不是走向極端。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章