Redux非同步解決方案之Redux-Thunk原理及原始碼解析

蔣鵬飛發表於2020-09-09

前段時間,我們寫了一篇Redux原始碼分析的文章,也分析了跟React連線的庫React-Redux的原始碼實現。但是在Redux的生態中還有一個很重要的部分沒有涉及到,那就是Redux的非同步解決方案。本文會講解Redux官方實現的非同步解決方案----Redux-Thunk,我們還是會從基本的用法入手,再到原理解析,然後自己手寫一個Redux-Thunk來替換它,也就是原始碼解析。

Redux-Thunk和前面寫過的ReduxReact-Redux其實都是Redux官方團隊的作品,他們的側重點各有不同:

Redux:是核心庫,功能簡單,只是一個單純的狀態機,但是蘊含的思想不簡單,是傳說中的“百行程式碼,千行文件”。

React-Redux:是跟React的連線庫,當Redux狀態更新的時候通知React更新元件。

Redux-Thunk:提供Redux的非同步解決方案,彌補Redux功能的不足。

本文手寫程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.js

基本用法

還是以我們之前的那個計數器作為例子,為了讓計數器+1,我們會發出一個action,像這樣:

function increment() {
  return {
    type: 'INCREMENT'
  }
};

store.dispatch(increment());

原始的Redux裡面,action creator必須返回plain object,而且必須是同步的。但是我們的應用裡面經常會有定時器,網路請求等等非同步操作,使用Redux-Thunk就可以發出非同步的action

function increment() {
  return {
    type: 'INCREMENT'
  }
};

// 非同步action creator
function incrementAsync() {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  }
}

// 使用了Redux-Thunk後dispatch不僅僅可以發出plain object,還可以發出這個非同步的函式
store.dispatch(incrementAsync());

下面再來看個更實際點的例子,也是官方文件中的例子:

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';

// createStore的時候傳入thunk中介軟體
const store = createStore(rootReducer, applyMiddleware(thunk));

// 發起網路請求的方法
function fetchSecretSauce() {
  return fetch('https://www.baidu.com/s?wd=Secret%20Sauce');
}

// 下面兩個是普通的action
function makeASandwich(forPerson, secretSauce) {
  return {
    type: 'MAKE_SANDWICH',
    forPerson,
    secretSauce,
  };
}

function apologize(fromPerson, toPerson, error) {
  return {
    type: 'APOLOGIZE',
    fromPerson,
    toPerson,
    error,
  };
}

// 這是一個非同步action,先請求網路,成功就makeASandwich,失敗就apologize
function makeASandwichWithSecretSauce(forPerson) {
  return function (dispatch) {
    return fetchSecretSauce().then(
      (sauce) => dispatch(makeASandwich(forPerson, sauce)),
      (error) => dispatch(apologize('The Sandwich Shop', forPerson, error)),
    );
  };
}

// 最終dispatch的是非同步action makeASandwichWithSecretSauce
store.dispatch(makeASandwichWithSecretSauce('Me'));

為什麼要用Redux-Thunk

在繼續深入原始碼前,我們先來思考一個問題,為什麼我們要用Redux-Thunk,不用它行不行?再仔細看看Redux-Thunk的作用:

// 非同步action creator
function incrementAsync() {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  }
}

store.dispatch(incrementAsync());

他僅僅是讓dispath多支援了一種型別,就是函式型別,在使用Redux-Thunk前我們dispatchaction必須是一個純物件(plain object),使用了Redux-Thunk後,dispatch可以支援函式,這個函式會傳入dispatch本身作為引數。但是其實我們不使用Redux-Thunk也可以達到同樣的效果,比如上面程式碼我完全可以不要外層的incrementAsync,直接這樣寫:

setTimeout(() => {
  store.dispatch(increment());
}, 1000);

這樣寫同樣可以在1秒後發出增加的action,而且程式碼還更簡單,那我們為什麼還要用Redux-Thunk呢,他存在的意義是什麼呢?stackoverflow對這個問題有一個很好的回答,而且是官方推薦的解釋。我再寫一遍也不會比他寫得更好,所以我就直接翻譯了:

----翻譯從這裡開始----

不要覺得一個庫就應該規定了所有事情!如果你想用JS處理一個延時任務,直接用setTimeout就好了,即使你使用了Redux也沒啥區別。Redux確實提供了另一種處理非同步任務的機制,但是你應該用它來解決你很多重複程式碼的問題。如果你沒有太多重複程式碼,使用語言原生方案其實是最簡單的方案。

直接寫非同步程式碼

到目前為止這是最簡單的方案,Redux也不需要特殊的配置:

store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
  store.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

(譯註:這段程式碼的功能是顯示一個通知,5秒後自動消失,也就是我們經常使用的toast效果,原作者一直以這個為例。)

相似的,如果你是在一個連線了Redux元件中使用:

this.props.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
  this.props.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

唯一的區別就是連線元件一般不需要直接使用store,而是將dispatch或者action creator作為props注入,這兩種方式對我們都沒區別。

如果你不想寫重複的action名字,你可以將這兩個action抽取成action creator而不是直接dispatch一個物件:

// actions.js
export function showNotification(text) {
  return { type: 'SHOW_NOTIFICATION', text }
}
export function hideNotification() {
  return { type: 'HIDE_NOTIFICATION' }
}

// component.js
import { showNotification, hideNotification } from '../actions'

this.props.dispatch(showNotification('You just logged in.'))
setTimeout(() => {
  this.props.dispatch(hideNotification())
}, 5000)

或者你已經通過connect()注入了這兩個action creator

this.props.showNotification('You just logged in.')
setTimeout(() => {
  this.props.hideNotification()
}, 5000)

到目前為止,我們沒有使用任何中介軟體或者其他高階技巧,但是我們同樣實現了非同步任務的處理。

提取非同步的Action Creator

使用上面的方式在簡單場景下可以工作的很好,但是你可能已經發現了幾個問題:

  1. 每次你想顯示toast的時候,你都得把這一大段程式碼抄過來抄過去。
  2. 現在的toast沒有id,這可能會導致一種競爭的情況:如果你連續快速的顯示兩次toast,當第一次的結束時,他會dispatchHIDE_NOTIFICATION,這會錯誤的導致第二個也被關掉。

為了解決這兩個問題,你可能需要將toast的邏輯抽取出來作為一個方法,大概長這樣:

// actions.js
function showNotification(id, text) {
  return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
  return { type: 'HIDE_NOTIFICATION', id }
}

let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
  // 給通知分配一個ID可以讓reducer忽略非當前通知的HIDE_NOTIFICATION
  // 而且我們把計時器的ID記錄下來以便於後面用clearTimeout()清除計時器
  const id = nextNotificationId++
  dispatch(showNotification(id, text))

  setTimeout(() => {
    dispatch(hideNotification(id))
  }, 5000)
}

現在你的元件可以直接使用showNotificationWithTimeout,再也不用抄來抄去了,也不用擔心競爭問題了:

// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')

// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')  

但是為什麼showNotificationWithTimeout()要接收dispatch作為第一個引數呢?因為他需要將action發給store。一般元件是可以拿到dispatch的,為了讓外部方法也能dispatch,我們需要給他dispath作為引數。

如果你有一個單例的store,你也可以讓showNotificationWithTimeout直接引入這個store然後dispatch action

// store.js
export default createStore(reducer)

// actions.js
import store from './store'

// ...

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  const id = nextNotificationId++
  store.dispatch(showNotification(id, text))

  setTimeout(() => {
    store.dispatch(hideNotification(id))
  }, 5000)
}

// component.js
showNotificationWithTimeout('You just logged in.')

// otherComponent.js
showNotificationWithTimeout('You just logged out.') 

這樣做看起來不復雜,也能達到效果,但是我們不推薦這種做法!主要原因是你的store必須是單例的,這讓Server Render實現起來很麻煩。在Server端,你會希望每個請求都有自己的store,比便於不同的使用者可以拿到不同的預載入內容。

一個單例的store也讓單元測試很難寫。測試action creator的時候你很難mock store,因為他引用了一個具體的真實的store。你甚至不能從外部重置store狀態。

所以從技術上來說,你可以從一個module匯出單例的store,但是我們不鼓勵這樣做。除非你確定加肯定你以後都不會升級Server Render。所以我們還是回到前面一種方案吧:

// actions.js

// ...

let nextNotificationId = 0
export function showNotificationWithTimeout(dispatch, text) {
  const id = nextNotificationId++
  dispatch(showNotification(id, text))

  setTimeout(() => {
    dispatch(hideNotification(id))
  }, 5000)
}

// component.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')

// otherComponent.js
showNotificationWithTimeout(this.props.dispatch, 'You just logged out.')  

這個方案就可以解決重複程式碼和競爭問題。

Thunk中介軟體

對於簡單專案,上面的方案應該已經可以滿足需求了。

但是對於大型專案,你可能還是會覺得這樣使用並不方便。

比如,似乎我們必須將dispatch作為引數傳遞,這讓我們分隔容器元件和展示元件變得更困難,因為任何發出非同步Redux action的元件都必須接收dispatch作為引數,這樣他才能將它繼續往下傳。你也不能僅僅使用connect()來繫結action creator,因為showNotificationWithTimeout()並不是一個真正的action creator,他返回的也不是Redux action

還有個很尷尬的事情是,你必須記住哪個action cerator是同步的,比如showNotification,哪個是非同步的輔助方法,比如showNotificationWithTimeout。這兩個的用法是不一樣的,你需要小心的不要傳錯了引數,也不要混淆了他們。

這就是我們為什麼需要找到一個“合法”的方法給輔助方法提供dispatch引數,並且幫助Redux區分出哪些是非同步的action creator,好特殊處理他們

如果你的專案中面臨著類似的問題,歡迎使用Redux Thunk中介軟體。

簡單來說,React Thunk告訴Redux怎麼去區分這種特殊的action----他其實是個函式:

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'

const store = createStore(
  reducer,
  applyMiddleware(thunk)
)

// 這個是普通的純物件action
store.dispatch({ type: 'INCREMENT' })

// 但是有了Thunk,他就可以識別函式了
store.dispatch(function (dispatch) {
  // 這個函式裡面又可以dispatch很多action
  dispatch({ type: 'INCREMENT' })
  dispatch({ type: 'INCREMENT' })
  dispatch({ type: 'INCREMENT' })

  setTimeout(() => {
    // 非同步的dispatch也可以
    dispatch({ type: 'DECREMENT' })
  }, 1000)
})

如果你使用了這個中介軟體,而且你dispatch的是一個函式,React Thunk會自己將dispatch作為引數傳進去。而且他會將這些函式action“吃了”,所以不用擔心你的reducer會接收到奇怪的函式引數。你的reducer只會接收到純物件action,無論是直接發出的還是前面那些非同步函式發出的。

這個看起來好像也沒啥大用,對不對?在當前這個例子確實是的!但是他讓我們可以像定義一個普通的action creator那樣去定義showNotificationWithTimeout

// actions.js
function showNotification(id, text) {
  return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
  return { type: 'HIDE_NOTIFICATION', id }
}

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  return function (dispatch) {
    const id = nextNotificationId++
    dispatch(showNotification(id, text))

    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
}

注意這裡的showNotificationWithTimeout跟我們前面的那個看起來非常像,但是他並不需要接收dispatch作為第一個引數。而是返回一個函式來接收dispatch作為第一個引數。

那在我們的元件中怎麼使用這個函式呢,我們當然可以這樣寫:

// component.js
showNotificationWithTimeout('You just logged in.')(this.props.dispatch)

這樣我們直接呼叫了非同步的action creator來得到內層的函式,這個函式需要dispatch做為引數,所以我們給了他dispatch引數。

然而這樣使用豈不是更尬,還不如我們之前那個版本的!我們為啥要這麼幹呢?

我之前就告訴過你:只要使用了Redux Thunk,如果你想dispatch一個函式,而不是一個純物件,這個中介軟體會自己幫你呼叫這個函式,而且會將dispatch作為第一個引數傳進去。

所以我們可以直接這樣幹:

// component.js
this.props.dispatch(showNotificationWithTimeout('You just logged in.'))

最後,對於元件來說,dispatch一個非同步的action(其實是一堆普通action)看起來和dispatch一個普通的同步action看起來並沒有啥區別。這是個好現象,因為元件就不應該關心那些動作到底是同步的還是非同步的,我們已經將它抽象出來了。

注意因為我們已經教了Redux怎麼區分這些特殊的action creator(我們稱之為thunk action creator),現在我們可以在任何普通的action creator的地方使用他們了。比如,我們可以直接在connect()中使用他們:

// actions.js

function showNotification(id, text) {
  return { type: 'SHOW_NOTIFICATION', id, text }
}
function hideNotification(id) {
  return { type: 'HIDE_NOTIFICATION', id }
}

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  return function (dispatch) {
    const id = nextNotificationId++
    dispatch(showNotification(id, text))

    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
}

// component.js

import { connect } from 'react-redux'

// ...

this.props.showNotificationWithTimeout('You just logged in.')

// ...

export default connect(
  mapStateToProps,
  { showNotificationWithTimeout }
)(MyComponent)

在Thunk中讀取State

通常來說,你的reducer會包含計算新的state的邏輯,但是reducer只有當你dispatchaction才會觸發。如果你在thunk action creator中有一個副作用(比如一個API呼叫),某些情況下,你不想發出這個action該怎麼辦呢?

如果沒有Thunk中介軟體,你需要在元件中新增這個邏輯:

// component.js
if (this.props.areNotificationsEnabled) {
  showNotificationWithTimeout(this.props.dispatch, 'You just logged in.')
}

但是我們提取action creator的目的就是為了集中這些在各個元件中重複的邏輯。幸運的是,Redux Thunk提供了一個讀取當前store state的方法。那就是除了傳入dispatch引數外,他還會傳入getState作為第二個引數,這樣thunk就可以讀取store的當前狀態了。

let nextNotificationId = 0
export function showNotificationWithTimeout(text) {
  return function (dispatch, getState) {
    // 不像普通的action cerator,這裡我們可以提前退出
    // Redux不關心這裡的返回值,沒返回值也沒關係
    if (!getState().areNotificationsEnabled) {
      return
    }

    const id = nextNotificationId++
    dispatch(showNotification(id, text))

    setTimeout(() => {
      dispatch(hideNotification(id))
    }, 5000)
  }
}

但是不要濫用這種方法!如果你需要通過檢查快取來判斷是否發起API請求,這種方法就很好,但是將你整個APP的邏輯都構建在這個基礎上並不是很好。如果你只是用getState來做條件判斷是否要dispatch action,你可以考慮將這些邏輯放到reducer裡面去。

下一步

現在你應該對thunk的工作原理有了一個基本的概念,如果你需要更多的例子,可以看這裡:https://redux.js.org/introduction/examples#async

你可能會發現很多例子都返回了Promise,這個不是必須的,但是用起來卻很方便。Redux並不關心你的thunk返回了什麼值,但是他會將這個值通過外層的dispatch()返回給你。這就是為什麼你可以在thunk中返回一個Promise並且等他完成:

dispatch(someThunkReturningPromise()).then(...)

另外你還可以將一個複雜的thunk action creator拆分成幾個更小的thunk action creator。這是因為thunk提供的dispatch也可以接收thunk,所以你可以一直巢狀的dispatch thunk。而且結合Promise的話可以更好的控制非同步流程。

在一些更復雜的應用中,你可能會發現你的非同步控制流程通過thunk很難表達。比如,重試失敗的請求,使用token進行重新授權認證,或者在一步一步的引導流程中,使用這種方式可能會很繁瑣,而且容易出錯。如果你有這些需求,你可以考慮下一些更高階的非同步流程控制庫,比如Redux Saga或者Redux Loop。可以看看他們,評估下,哪個更適合你的需求,選一個你最喜歡的。

最後,不要使用任何庫(包括thunk)如果你沒有真實的需求。記住,我們的實現都是要看需求的,也許你的需求這個簡單的方案就能滿足:

store.dispatch({ type: 'SHOW_NOTIFICATION', text: 'You logged in.' })
setTimeout(() => {
  store.dispatch({ type: 'HIDE_NOTIFICATION' })
}, 5000)

不要跟風嘗試,除非你知道你為什麼需要這個!

----翻譯到此結束----

StackOverflow的大神Dan Abramov對這個問題的回答實在太細緻,太到位了,以致於我看了之後都不敢再寫這個原因了,以此翻譯向大神致敬,再貼下這個回答的地址:https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559

PS: Dan Abramov是Redux生態的核心作者,這幾篇文章講的ReduxReact-ReduxRedux-Thunk都是他的作品。

原始碼解析

上面關於原因的翻譯其實已經將Redux適用的場景和原理講的很清楚了,下面我們來看看他的原始碼,自己仿寫一個來替換他。照例我們先來分析下要點:

  1. Redux-Thunk是一個Redux中介軟體,所以他遵守Redux中介軟體的正規化。
  2. thunk是一個可以dispatch的函式,所以我們需要改寫dispatch讓他接受函式引數。

Redux中介軟體正規化

在我前面那篇講Redux原始碼的文章講過中介軟體的正規化以及Redux中這塊原始碼是怎麼實現的,沒看過或者忘了的朋友可以再去看看。我這裡再簡單提一下,一個Redux中介軟體結構大概是這樣:

function logger(store) {
  return function(next) {
    return function(action) {
      console.group(action.type);
      console.info('dispatching', action);
      let result = next(action);
      console.log('next state', store.getState());
      console.groupEnd();
      return result
    }
  }
}

這裡注意幾個要點:

  1. 一箇中介軟體接收store作為引數,會返回一個函式
  2. 返回的這個函式接收老的dispatch函式作為引數(也就是程式碼中的next),會返回一個新的函式
  3. 返回的新函式就是新的dispatch函式,這個函式裡面可以拿到外面兩層傳進來的store和老dispatch函式

仿照這個正規化,我們來寫一下thunk中介軟體的結構:

function thunk(store) {
  return function (next) {
    return function (action) {
      // 先直接返回原始結果
      let result = next(action);
      return result
    }
  }
}

處理thunk

根據我們前面講的,thunk是一個函式,接收dispatch getState兩個引數,所以我們應該將thunk拿出來執行,然後給他傳入這兩個引數,再將它的返回值直接返回就行。

function thunk(store) {
  return function (next) {
    return function (action) {
      // 從store中解構出dispatch, getState
      const { dispatch, getState } = store;

      // 如果action是函式,將它拿出來執行,引數就是dispatch和getState
      if (typeof action === 'function') {
        return action(dispatch, getState);
      }

      // 否則按照普通action處理
      let result = next(action);
      return result
    }
  }
}

接收額外引數withExtraArgument

Redux-Thunk還提供了一個API,就是你在使用applyMiddleware引入的時候,可以使用withExtraArgument注入幾個自定義的引數,比如這樣:

const api = "http://www.example.com/sandwiches/";
const whatever = 42;

const store = createStore(
  reducer,
  applyMiddleware(thunk.withExtraArgument({ api, whatever })),
);

function fetchUser(id) {
  return (dispatch, getState, { api, whatever }) => {
    // 現在你可以使用這個額外的引數api和whatever了
  };
}

這個功能要實現起來也很簡單,在前面的thunk函式外面再包一層就行:

// 外面再包一層函式createThunkMiddleware接收額外的引數
function createThunkMiddleware(extraArgument) {
  return function thunk(store) {
    return function (next) {
      return function (action) {
        const { dispatch, getState } = store;

        if (typeof action === 'function') {
          // 這裡執行函式時,傳入extraArgument
          return action(dispatch, getState, extraArgument);  
        }

        let result = next(action);
        return result
      }
    }
  }
}

然後我們的thunk中介軟體其實相當於沒傳extraArgument

const thunk = createThunkMiddleware();

而暴露給外面的withExtraArgument函式就直接是createThunkMiddleware了:

thunk.withExtraArgument = createThunkMiddleware;

原始碼解析到此結束。啥,這就完了?是的,這就完了!Redux-Thunk就是這麼簡單,雖然背後的思想比較複雜,但是程式碼真的只有14行!我當時也震驚了,來看看官方原始碼吧:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

總結

  1. 如果說Redux是“百行程式碼,千行文件”,那Redux-Thunk就是“十行程式碼,百行思想”。
  2. Redux-Thunk最主要的作用是幫你給非同步action傳入dispatch,這樣你就不用從呼叫的地方手動傳入dispatch,從而實現了呼叫的地方和使用的地方的解耦。
  3. ReduxRedux-Thunk讓我深深體會到什麼叫“程式設計思想”,程式設計思想可以很複雜,但是實現可能並不複雜,但是卻非常有用。
  4. 在我們評估是否要引入一個庫時最好想清楚我們為什麼要引入這個庫,是否有更簡單的方案。

本文手寫程式碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.js

參考資料

Redux-Thunk文件:https://github.com/reduxjs/redux-thunk

Redux-Thunk原始碼: https://github.com/reduxjs/redux-thunk/blob/master/src/index.js

Dan Abramov在StackOverflow上的回答: https://stackoverflow.com/questions/35411423/how-to-dispatch-a-redux-action-with-a-timeout/35415559#35415559

文章的最後,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支援是作者持續創作的動力。

作者博文GitHub專案地址: https://github.com/dennis-jiang/Front-End-Knowledges

作者掘金文章彙總:https://juejin.im/post/5e3ffc85518825494e2772fd

我也搞了個公眾號[進擊的大前端],不打廣告,不寫水文,只發高質量原創,歡迎關注~

QRCode

相關文章