react-reduxのパフォーマンスについて

エンジニアの原です。

これ、数人同じ事思ってる人いたみたいでした。よかった。

http://anect.hatenablog.com/entry/2017/06/09/151000:embed:cit

ブログ内では「mapStateToPropsとmapDispatchToPropsを使う理由が分からない、mergePropsで全部やるのがシンプルで良いじゃないか」というものでしたが

今回react-reduxのソースを解説しつつそれらの役割と必要性についてと実際どうするべきか、について少し考えました。

TL;DR

結論から言うと

「場合によって(というか多くの場合)はmapStateToPropsとmapDispatchToPropsをちゃんと使わないとパフォーマンスが下がる」

です。

以下、ソースの解説と自分の意見です。

react-reduxの実装の話

ちょっとだけreact-reduxの実装の話をします。

以下の説明はconnectにオプション(第4引数)未定義の場合に限ります。

connectのソースは以下になります。

https://github.com/reactjs/react-redux/blob/master/src/connect/connect.js

connectはHightOrderComponentを作成する関数を返します。以下はconnect.jsの一部です。

export function createConnect({
  connectHOC = connectAdvanced,
  mapStateToPropsFactories = defaultMapStateToPropsFactories,
  mapDispatchToPropsFactories = defaultMapDispatchToPropsFactories,
  mergePropsFactories = defaultMergePropsFactories,
  selectorFactory = defaultSelectorFactory
} = {}) {
  return function connect(
    mapStateToProps,
    mapDispatchToProps,
    mergeProps,
    {
      pure = true,
      areStatesEqual = strictEqual,
      areOwnPropsEqual = shallowEqual,
      areStatePropsEqual = shallowEqual,
      areMergedPropsEqual = shallowEqual,
      ...extraOptions
    } = {}
  ) {
    const initMapStateToProps = match(mapStateToProps, mapStateToPropsFactories, 'mapStateToProps')
    const initMapDispatchToProps = match(mapDispatchToProps, mapDispatchToPropsFactories, 'mapDispatchToProps')
    const initMergeProps = match(mergeProps, mergePropsFactories, 'mergeProps')

    return connectHOC(selectorFactory, {
      // used in error messages
      methodName: 'connect',

       // used to compute Connect's displayName from the wrapped component's displayName.
      getDisplayName: name => `Connect(${name})`,

      // if mapStateToProps is falsy, the Connect component doesn't subscribe to store state changes
      shouldHandleStateChanges: Boolean(mapStateToProps),

      // passed through to selectorFactory
      initMapStateToProps,
      initMapDispatchToProps,
      initMergeProps,
      pure,
      areStatesEqual,
      areOwnPropsEqual,
      areStatePropsEqual,
      areMergedPropsEqual,

      // any extra options args can override defaults of connect or connectAdvanced
      ...extraOptions
    })
  }
}
export default createConnect()

通常、

connect(mapStateToProps, mapDispatchToProps, mergeProps)(MyComponent)

このように使う時はconnectHOCというHighOrderComponentが返ります。

connectHOCの実体はconnectAdvanceです。

https://github.com/reactjs/react-redux/blob/master/src/components/connectAdvanced.js

このComponentはReduxStateとComponentのPropsの変更を監視して、必要があればrenderを実行するというものです。

ReactではComponentのrenderを制御するshouldComponentUpdateというメソッドがあります。

connectAdvanceでの実装は

shouldComponentUpdate() {
    return this.selector.shouldComponentUpdate
}

となっています。selectorの実体は

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
      }
    }
  }

このsourceSelectorというのがReduxStoreの変更、ComponentのPropsの変更、mapStateToPropsの変更、mergePropsの変更をチェックして変更が合った場合はあたらしいオブジェクトを返すという動作をします。

そして、この変更をチェックする関数がconnectHOCの引数の areStatesEqual, areOwnPropsEqual, areStatePropsEqual, areMergedPropsEqual,

です。connectのオプション(第4引数)で実装を指定できますが、デフォルトは

      areStatesEqual = strictEqual,
      areOwnPropsEqual = shallowEqual,
      areStatePropsEqual = shallowEqual,
      areMergedPropsEqual = shallowEqual,

です。

strictEqualはただの===での比較で、shallowEqualは以下の実装になっています。

https://github.com/reactjs/react-redux/blob/master/src/utils/shallowEqual.js

sourceSelectorの実体はselectorFactory.jsのpureFinalPropsSelectorFactoryになります。(デフォルトpure=trueなので)

https://github.com/reactjs/react-redux/blob/master/src/connect/selectorFactory.js

このコード中で使われているmergePropsはconnectに渡したmergePropsではなくinitMergePropsの返り値で有ることに注意して下さい。

ここでのinitMergePropsの実体はmergeProps.jsのwrapMergePropsFuncの返り値です。

https://github.com/reactjs/react-redux/blob/master/src/connect/mergeProps.js

この中で前回のmergePropsの結果と今回の結果をareMergePropsEqualで比較して違えば新しい結果を、同じであれば前回の結果を返す、という動作をしています。

なので、mergePropsの結果が変わらなければconnectAdvanceのrenderは実行されないということがわかります。

パフォーマンスが悪くならないケース

ここで言う「パフォーマンスが悪くならない」とはconnectが必要以上にレンダリングされないことを指します。

下記の例ではパフォーマンスにさほど問題は起きないでしょう。

const mergeProps = (state, { dispatch }, own) => ({ name: state.user.name });
connect(state => state, dispatch => ({ dispatch }), mergeProps);

stateのどれか1つでも変化するとmapStateToPropsの比較がfalseになるのでmergePropsの結果の比較が行われます。

もし上記の例のようにmergePropsにAction等の関数がない場合、nameの値が変わらなければ、mergePropsの比較がtrueになるのでレンダリングは行われません。

パフォーマンスが悪くなるケース

下記の例ではなにか1つでもReduxStateの値が変わるとレンダリングが行われます。

const mergeProps = (state, { dispatch }, own) => ({ 
    name: state.user.name,
    onSubmit: () => {
       const token = state.user.token;
       const name = state.edit.name;
       fetch(...);
    }, 
});
connect(state => state, dispatch => ({ dispatch }), mergeProps);

上記onSubmitに無名関数を渡しています。この無名関数はmergePropsが実行されるたびに生成されるので、mergePropsの比較は必ずfalseになります。

ということは

ドキュメントに従ってreact-reudxにレンダリングのパフォーマンスまで任せる(子の最適化はしない)というのであればmapStateToPropsとmapDispatchToPropsをちゃんと定義するのが良いでしょう。

ただ個人的にはmergePropsにまとめて、各Componentがレンダリングの最適化を行うのが良いかなと思ってます。

Presentational Componentの責務を考える

一番の理由は、描画に関係ないロジックがComponentに漏れるのは避けたいからです。(例えば、上記のonSubmitがonSubmit(token, name)となるような)

これは私が基本的にPresentational ComponentとContainerd Componentに分けて設計を行うことが多く、Presentatioanl ComponentはほぼStateless Functional Component(SFC)で作ることが多い事に関係します。

例えば上記の例だと、ReduxStateにUserの基本情報(tokenとか)、変更内容内容(変更されたnameとか)があるので、onSubmitが押されたらReduxStateからtokenとnameを取ってきてfetchを実行すれば良いのでonSubmitが引数を必要としません。

// 描画とイベントの取得のみに務める
export default ({ name, onSubmit}) => (
  <>
      <div>{name}</div>
      <button onClick={onSubmit}>Submit</button>
  </>
);

こうすることでonSubmitはボタンが押された事だけを通知するというシンプルな役割になります。

逆にonSubmit(token, name)としてしまうと、onSubmitの内部処理にtokenとnameが必要であるというロジックがComponentが知っておく必要があります。Presentational Componentとして振る舞うのであれば、表示とイベントの取得のみを果たすべきであり、イベントが起きた後のロジックに関する事(tokenが必要だよ、とか)はさせるべきではないと考えてます。

// tokenは描画しないけどonSubmitに必要
export default ({ name, token, onSubmit}) => (
  <>
      <div>{name}</div>
      <button onClick={() => onSubmit(token, name)}>Submit</button>
  </>
);

また、tokenのような描画に一切関係ない情報をPropsとしてComponentに提供しなければならないという点もPresentationa Componentとして良くないと思います。

仮にonSubmitで送る必要があるデータが増えた場合、このComponentの見え方には影響が無くても変更しなければなりません。これは明らかにロジックがViewに漏れています。

私の結論

とか色々考えた結果、react-reduxにはReduxStoreの監視とデータのフィルタリングだけをお願いしてレンダリングのパフォーマンス部分に関しては個々のコンポーネントが担う方が良いのではないかという結論に至ってます(今現在)。

なので、react-reduxの部分は

const mapStateToProps = state => state;
const mapDispatchToProps = dispatch => ({ dispatch });
const mergeProps = (state, {dispatch}, own) => ({
    name: state.user.name,
    onSubmit: await () => {
      const token = state.user.token;
      const name = state.edit.name;
      const response = await fetch(...);
    }
});
const option = { areStatePropsEqual: () => false }; // shallowEqualを使ってもstate全渡しなので必ずfalseになるため無条件でfalseに
connect(mapStateToProps, mapDispatchToProps, mergeProps, option)(MyComponent);

みたいな感じでどうかなーと考えています。

あとはMyComponentのchildrenの各々がReact.PureComponentなりrecompose.pureなりで最適化していれば、レンダリングのコストは抑えられると思います。