跳到主要內容
黯羽輕揚每天積累一點點

react-redux 原始碼解讀

免費2017-10-29#JS#react-redux原理#react-redux connect#react-redux Provider#react-redux剖析

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 部分:

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

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

  3. 不小水源不要亂冒(內置性能優化,對比緩存的 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
  )
}

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

另外還有 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.註釋依然足夠詳盡。

評論

暫無評論,快來發表你的看法吧

提交評論