Redux異步解決方案之Redux-Thunk原理及源碼解析

前段時間,咱們寫了一篇Redux源碼分析的文章,也分析了跟React鏈接的庫React-Redux的源碼實現。可是在Redux的生態中還有一個很重要的部分沒有涉及到,那就是Redux的異步解決方案。本文會講解Redux官方實現的異步解決方案----Redux-Thunk,咱們仍是會從基本的用法入手,再到原理解析,而後本身手寫一個Redux-Thunk來替換它,也就是源碼解析。javascript

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

Redux:是核心庫,功能簡單,只是一個單純的狀態機,可是蘊含的思想不簡單,是傳說中的「百行代碼,千行文檔」。

React-Redux:是跟React的鏈接庫,當Redux狀態更新的時候通知React更新組件。java

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

本文手寫代碼已經上傳GitHub,你們能夠拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/blob/master/Examples/React/redux-thunk/src/myThunk.jsgit

基本用法

仍是以咱們以前的那個計數器做爲例子,爲了讓計數器+1,咱們會發出一個action,像這樣:github

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());

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

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的做用:segmentfault

// 異步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,直接這樣寫:api

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

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

相關文章
相關標籤/搜索