react-redux源碼解讀

寫在前面
react-redux作爲膠水一樣的東西,似乎沒有深入瞭解的必要,但實際上,作爲數據層(redux)與UI層(react)的連接處,其實現細節對整體性能有着決定性的影響。組件樹胡亂update的成本,要比多跑幾遍reducer樹的成本高得多,所以有必要了解其實現細節

仔細瞭解react-redux的好處之一是可以對性能有基本的認識,考慮一個問題:

dispatch({type: 'UPDATE_MY_DATA', payload: myData})
組件樹中某個角落的這行代碼,帶來的性能影響是什麼?幾個子問題:

1.導致哪些reducer被重新計算了?

2.引發的視圖更新從哪個組件開始?

3.哪些組件的render被調用了?

4.每個葉子組件都被diff波及了嗎?爲什麼?

如果無法準確回答這幾個問題,對性能肯定是心裏沒底的

一.作用
首先,明確redux只是一個數據層,而react只是一個UI層,二者之間是沒有聯繫的

如果左右手分別拿着redux和react,那麼實際情況應該是這樣的:

redux把數據結構(state)及各字段的計算方式(reducer)都定好了

react根據視圖描述(Component)把初始頁面渲染出來

可能是這個樣子:


       redux      |      react

myUniversalState  |  myGreatUI
  human           |    noOneIsHere
    soldier       |
      arm         |
    littleGirl    |
      toy         |
  ape             |    noOneIsHere
    hoho          |
  tree            |    someTrees
  mountain        |    someMountains
  snow            |    flyingSnow

左邊redux裏什麼都有,但是react不知道,只顯示了默認元素(沒有沒有數據),有一些組件局部state和零散的props傳遞,頁面就像一幀靜態圖,組件樹看起來只是由一些管道連接起來的大架子

現在我們考慮把react-redux加進來,那麼就會變成這樣子:


             react-redux
       redux     -+-     react

myUniversalState  |  myGreatUI
            HumanContainer
  human          -+-   humans
    soldier       |      soldiers
            ArmContainer
      arm        -+-       arm
    littleGirl    |      littleGirl
      toy         |        toy
            ApeContainer
  ape            -+-   apes
    hoho          |      hoho
           SceneContainer
  tree           -+-   Scene
  mountain        |     someTrees
  snow            |     someMountains
                         flyingSnow

注意,Arm交互比較複雜,不適合由上層(HumanContainer)控制,所以出現了嵌套Container

Container把redux手裏的state交給react,這樣初始數據就有了,那麼如果要更新視圖呢?

Arm.dispatch({type: 'FIRST_BLOOD', payload: warData})
有人打響了第一槍,導致soldier掛了一個(state change),那麼這些部分要發生變化:


                react-redux
          redux     -+-     react
myNewUniversalState  |  myUpdatedGreatUI
              HumanContainer
     human          -+-   humans
       soldier       |      soldiers
                     |      diedSoldier
                ArmContainer
         arm        -+-       arm
                     |          inactiveArm

頁面上出現一個掛掉的soldier和一支掉地上的arm(update view),其它部分(ape, scene)一切安好

上面描述的就是react-redux的作用:

把state從redux傳遞到react

並負責在redux state change後update react view

那麼猜也知道,實現分爲3部分:

給管道連接起來的大架子添上一個個小水源(通過Container把state作爲props注入下方view)

讓小水源冒水(監聽state change,通過Container的setState來更新下方view)

不小水源不要亂冒(內置性能優化,對比緩存的state, props看有沒有必要更新)

二.關鍵實現
源碼關鍵部分如下:


// from: src/components/connectAdvanced/Connect.onStateChange
onStateChange() {
  // state change時重新計算props
  this.selector.run(this.props)

  // 當前組件不用更新的話,通知下方container檢查更新
  // 要更新的話,setState空對象強制更新,延後通知到didUpdate
  if (!this.selector.shouldComponentUpdate) {
    this.notifyNestedSubs()
  } else {
    this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate
    // 通知Container下方的view更新
//!!! 這裏是把redux與react連接起來的關鍵
    this.setState(dummyState)
  }
}

最重要的那個setState就在這裏,dispatch action後視圖更新的祕密是這樣:


1.dispatch action
2.redux計算reducer得到newState
3.redux觸發state change(調用之前通過store.subscribe註冊的state變化監聽器)
4.react-redux頂層Container的onStateChange觸發
  1.重新計算props
  2.比較新值和緩存值,看props變了沒,要不要更新
  3.要的話通過setState({})強制react更新
  4.通知下方的subscription,觸發下方關注state change的Container的onStateChange,檢查是否需要更新view

第3步裏,react-redux向redux註冊store change監聽的動作發生在connect()(myComponent)時,事實上react-redux只對頂層Container直接監聽了redux的state change,下層Container都是內部傳遞通知的,如下:


// from: src/utils/Subscription/Subscription.trySubscribe
trySubscribe() {
  if (!this.unsubscribe) {
    // 沒有父級觀察者的話,直接監聽store change
    // 有的話,添到父級下面,由父級傳遞變化
    this.unsubscribe = this.parentSub
      ? this.parentSub.addNestedSub(this.onStateChange)
      : this.store.subscribe(this.onStateChange)
  }
}

這裏不直接監聽redux的state change,而非要自己維護Container的state change listener,是爲了實現次序可控,例如上面提到的:


// 要更新的話,延後通知到didUpdate
this.componentDidUpdate = this.notifyNestedSubsOnComponentDidUpdate

這樣保證了listener觸發順序是按照組件樹層級順序的,先通知大子樹更新,大子樹更新完畢後,再通知小子樹更新

更新的整個過程就是這樣,至於“通過Container把state作爲props注入下方view”這一步,沒什麼好說的,如下:


// from: src/components/connectAdvanced/Connect.render
render() {
  return createElement(WrappedComponent, this.addExtraProps(selector.props))
}

根據WrappedComponent需要的state字段,造一份props,通過React.createElement注入進去。ContainerInstance.setState({})時,這個render函數被重新調用,新的props被注入到view,view will receive props…視圖更新就真正開始了

三.技巧
讓純函數擁有狀態


function makeSelectorStateful(sourceSelector, store) {
  // wrap the selector in an object that tracks its results between runs.
  const selector = {
    run: function runComponentSelector(props) {
      try {
        const nextProps = sourceSelector(store.getState(), props)
        if (nextProps !== selector.props || selector.error) {
          selector.shouldComponentUpdate = true
          selector.props = nextProps
          selector.error = null
        }
      } catch (error) {
        selector.shouldComponentUpdate = true
        selector.error = error
      }
    }
  }

  return selector
}

把純函數用對象包起來,就可以有局部狀態了,作用和new Class Instance類似。這樣就把純的部分與不純的部分分離開了,純的依然純,不純的在外面,class不如這個乾淨

默認參數與對象解構


function connectAdvanced(
  selectorFactory,
  // options object:
  {
    getDisplayName = name => `ConnectAdvanced(${name})`,
    methodName = 'connectAdvanced',
    renderCountProp = undefined,
    shouldHandleStateChanges = true,
    storeKey = 'store',
    withRef = false,
    // additional options are passed through to the selectorFactory
    ...connectOptions
  } = {}
) {
  const selectorFactoryOptions = {
    // 展開 還原回去
    ...connectOptions,
    getDisplayName,
    methodName,
    renderCountProp,
    shouldHandleStateChanges,
    storeKey,
    withRef,
    displayName,
    wrappedComponentName,
    WrappedComponent
  }
}

可以簡化成這樣:


function f({a = 'a', b = 'b', ...others} = {}) {
    console.log(a, b, others);
    const newOpts = {
      ...others,
      a,
      b,
      s: 's'
    };
    console.log(newOpts);
}
// test
f({a: 1, c: 2, f: 0});
// 輸出
// 1 "b" {c: 2, f: 0}
// {c: 2, f: 0, a: 1, b: "b", s: "s"}

這裏用到3個es6+小技巧:

默認參數。防止解構時右邊undefined報錯

對象解構。把剩餘屬性都包進others對象裏

展開運算符。把others展開,屬性merge到目標對象上

默認參數是es6特性,沒什麼好說的。對象解構是Stage 3 proposal,...others是其基本用法。展開運算符把對象展開,merge到目標對象上,也不復雜

比較有意思的是這裏把對象解構和展開運算符配合使用,實現了這種需要對參數做打包-還原的場景,如果不用這2個特性,可能需要這樣做:


function connectAdvanced(
  selectorFactory,
  connectOpts,
  otherOpts
) {
  const selectorFactoryOptions = extend({},
    otherOpts,
    getDisplayName,
    methodName,
    renderCountProp,
    shouldHandleStateChanges,
    storeKey,
    withRef,
    displayName,
    wrappedComponentName,
    WrappedComponent
  )
}

需要清楚地區分connectOpts和otherOpts,實現上會麻煩一些,組合運用這些技巧的話,代碼相當簡練

另外還有1個es6+小技巧:


addExtraProps(props) {
  //! 技巧 淺拷貝保證最少知識
  //! 淺拷貝props,不把別人不需要的東西傳遞出去,否則影響GC
  const withExtras = { ...props }
}

多一份引用就多一份內存泄漏的風險,不需要的不應該給(最少知識)

參數模式匹配

function match(arg, factories, name) {
  for (let i = factories.length - 1; i >= 0; i--) {
    const result = factories[i](arg)
    if (result) return result
  }

  return (dispatch, options) => {
    throw new Error(`Invalid value of type ${typeof arg} for ${name} argument when connecting component ${options.wrappedComponentName}.`)
  }
}

其中factories是這樣:


// mapDispatchToProps
[
  whenMapDispatchToPropsIsFunction,
  whenMapDispatchToPropsIsMissing,
  whenMapDispatchToPropsIsObject
]
// mapStateToProps
[
  whenMapStateToPropsIsFunction,
  whenMapStateToPropsIsMissing
]

針對參數的各種情況建立一系列case函數,然後讓參數依次流經所有case,匹配任意一個就返回其結果,都不匹配就進入錯誤case

類似於switch-case,用來對參數做模式匹配,這樣各種case都被分解出去了,各自職責明確(各case函數的命名非常準確)

懶參數


function wrapMapToPropsFunc() {
  // 猜完立即算一遍props
  let props = proxy(stateOrDispatch, ownProps)
  // mapToProps支持返回function,再猜一次
  if (typeof props === 'function') {
    proxy.mapToProps = props
    proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
    props = proxy(stateOrDispatch, ownProps)
  }
}

其中,懶參數是指:


// 把返回值作爲參數,再算一遍props
if (typeof props === 'function') {
  proxy.mapToProps = props
  proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
  props = proxy(stateOrDispatch, ownProps)
}

這樣實現和react-redux面臨的場景有關,支持返回function主要是爲了支持組件實例級(默認是組件級)的細粒度mapToProps控制。這樣就能針對不同組件實例,給不同的mapToProps,支持進一步提升性能

從實現上來看,相當於把實際參數延後了,支持傳入一個參數工廠作爲參數,第一次把外部環境傳遞給工廠,工廠再根據環境造出實際參數。添了工廠這個環節,就把控制粒度細化了一層(組件級的細化到了組件實例級,外部環境即組件實例信息)

P.S.關於懶參數的相關討論見https://github.com/reactjs/react-redux/pull/279

四.疑問
1.默認的props.dispatch哪裏來的?


connect()(MyComponent)

不給connect傳任何參數,MyComponent實例也能拿到一個prop叫dispatch,是在哪裏偷偷掛上的?


function whenMapDispatchToPropsIsMissing(mapDispatchToProps) {
  return (!mapDispatchToProps)
    // 就是這裏掛上去的,沒傳mapDispatchToProps的話,默認把dispatch掛到props上
    ? wrapMapToPropsConstant(dispatch => ({ dispatch }))
    : undefined
}

默認內置了一個mapDispatchToProps = dispatch => ({ dispatch }),所以組件props身上有dispatch,如果指定了mapDispatchToProps,就不給掛了

2.多級Container會不會面臨性能問題?
考慮這種場景:


App
  HomeContainer
    HomePage
      HomePageHeader
        UserContainer
          UserPanel
            LoginContainer
              LoginButton

出現了嵌套的container,那麼在HomeContainer關注的state發生變化時,會不會走很多遍視圖更新?比如:


HomeContainer update-didUpdate
UserContainer update-didUpdate
LoginContainer update-didUpdate

如果是這樣,輕輕一發dispatch,導致3個子樹更新,感覺性能要炸了

實際上不是這樣。對於多級Container,走兩遍的情況確實存在,只是這裏的走兩遍不是指視圖更新,而是說state change通知

上層Container在didUpdate後會通知下方Container檢查更新,可能會在小子樹再走一遍。但在大子樹更新的過程中,走到下方Container時,小子樹在這個時機就開始更新了,大子樹didUpdate後的通知只會讓下方Container空走一遍檢查,不會有實際更新

檢查的具體成本是分別對state和props做===比較和淺層引用比較(也是先===比較),發現沒變就結束了,所以每個下層Container的性能成本是兩個===比較,不要緊。也就是說,不用擔心使用嵌套Container帶來的性能開銷

五.源碼分析
Github地址:https://github.com/ayqy/react-redux-5.0.6

P.S.註釋依然足夠詳盡。

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