メインコンテンツへ移動

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 を詳しく理解する利点の 1 つは、パフォーマンスについて基本的な認識を持てることです。次の問題を考慮してください:

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 の受け渡しがあり、ページは 1 フレームの静的な図のようであり、コンポーネントツリーはいくつかのパイプで接続された大きな枠組みのように見えます

ここで 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 が 1 人倒れた(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 後に react view を update する責任を負う

推測するに、実装は 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 を展開し、属性をターゲットオブジェクトにマージ

デフォルトパラメータは es6 の機能で、言うことはありません。オブジェクト分割代入は Stage 3 proposal で、...othersはその基本的な使い方です。展開演算子はオブジェクトを展開し、ターゲットオブジェクトにマージします。複雑ではありません

興味深いのは、ここでオブジェクト分割代入と展開演算子を組み合わせて使用し、パラメータに対してパック-復元を行う必要があるシナリオを実現していることです。これら 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 }
}

参照が 1 つ増えるとメモリリークのリスクが 1 つ増えます。必要ないものは渡すべきではありません(最小知識

パラメータパターンマッチング

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 を 1 回計算
  let props = proxy(stateOrDispatch, ownProps)
  // mapToProps は function の返却をサポートし、もう 1 回推測
  if (typeof props === 'function') {
    proxy.mapToProps = props
    proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
    props = proxy(stateOrDispatch, ownProps)
  }
}

ここで、遅延パラメータとは:

// 戻り値をパラメータとして、props をもう 1 回計算
if (typeof props === 'function') {
  proxy.mapToProps = props
  proxy.dependsOnOwnProps = getDependsOnOwnProps(props)
  props = proxy(stateOrDispatch, ownProps)
}

この実装は react-redux が直面するシナリオに関連しています。function の返却をサポートするのは、主にコンポーネントインスタンスレベル(デフォルトはコンポーネントレベル)の細かい粒度の mapToProps 制御をサポートするためです。これにより異なるコンポーネントインスタンスに対して異なる mapToProps を与えることができ、パフォーマンスをさらに向上させることができます

実装から見ると、実際のパラメータを遅延させており、パラメータファクトリをパラメータとして传入することをサポートしています。1 回目に外部環境をファクトリに渡し、ファクトリが環境に基づいて実際のパラメータを作成します。ファクトリという環節を追加することで、制御粒度を 1 レベル細かくしています(コンポーネントレベルからコンポーネントインスタンスレベルに、外部環境はコンポーネントインスタンス情報)

P.S.遅延パラメータに関する議論はhttps://github.com/reactjs/react-redux/pull/279を参照

四.疑問

1.デフォルトの props.dispatch はどこから来るのか?

connect()(MyComponent)

connect にパラメータを何も渡さなくても、MyComponent インスタンスはdispatchという prop を取得できます。どこでこっそり取り付けられているのでしょうか?

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

この場合、軽い 1 回の dispatch で 3 つのサブツリーが更新され、パフォーマンスが爆発する感觉です

実際にはそうではありません。多级 Container に対して、2 回通る状況は確かに存在しますが、ここでの 2 回通るとはビュー更新を指すのではなく、state change 通知を指します

上位 Container は didUpdate 後に下の Container に更新チェックを通知し、小さなサブツリーでもう 1 回通る可能性があります。しかし大きなサブツリーの更新過程中、下の Container に到達した時、小さなサブツリーはこのタイミングで更新を開始します。大きなサブツリーの didUpdate 後の通知は下の Container に空で 1 回チェックさせるだけで、実際の更新はありません

チェックの具体的なコストは state と props に対してそれぞれ===比較と浅い参照比較(これも先に===比較)を行い、変化がなければ終了します。したがって、各下の Container のパフォーマンスコストは 2 つの===比較であり、問題ありません。つまり、ネストされた Container の使用によるパフォーマンスオーバーヘッドを心配する必要はありません

五.ソースコード分析

Github アドレス:https://github.com/ayqy/react-redux-5.0.6

P.S.注釈は依然として十分に詳細です。

コメント

コメントはまだありません

コメントを書く