Reactを使ったプロダクトのパフォーマンスを改善した話

技術推進室の色川です。
ここ一年くらいランキングシェアというキュレーションメディアのフロントエンドを担当しています。その記事作成画面をReactで実装しており、先日アイテムの移動をDrag&Dropで行えるようにしたのですが、動きはガクガク、ブラウザは途中で固まる、という有様で、パフォーマンスチューニングを余儀なくされたのでその内容を紹介します。
[ランキングシェアの記事作成画面]
※画面は開発中のものです。(動画にしたら?との意見があったのですが、Windowsで画面を録画する方法が分からず…)
Reactのパフォーマンス改善の基本
Reactはコンポーネントをツリーとして管理していて、あるコンポーネントの更新が必要になるとそのコンポーネントをルートとするサブツリー全体を更新します。
引用元:Reactive, Component-based UIs with React | Constant Contact Tech Blog
更新するコンポーネントによっては大量のサブコンポーネントまで一緒に更新されてしまいますが、Reactは仮想DOMの仕組みにより差分だけを実際のDOMに反映するため、レンダリングに関して手を入れる余地はありません。
改善可能なのは「仮想DOMの再構築~差分計算」の部分です。更新対象のサブツリーに含まれるコンポーネントの中には、まったく更新の必要がないものもあったりします。そのようなコンポーネントを探し出し、shouldComponentUpdate
メソッドを定義して、更新不要ならfalse
を返すようにすることで、そのコンポーネントを再構築の対象から外すことができるのです。
以上を踏まえた上で調査・改善を行っていきます。
ボトルネック調査
Reactの公式ドキュメントに載っているreact-addons-perf
というプロファイリングツールを使います。
Performance Tools | React
まずはインストール。バージョンはReact本体と同じものを指定してください。
npm install --save-dev react-addons-perf@0.14.3
(ソース見るとmodule.exports = require('react/lib/ReactDefaultPerf');
してるだけなのでインストールしなくてもいいのかな…?)
次に、ブラウザコンソールからPerf
オブジェクトを使えるよう、プログラム内でwindow
オブジェクトにセットしておきます。
window.Perf = require('react-addons-perf');
準備が整ったら以下の手順でプロファイリングを行います。
- コンソールで
Perf.start()
を実行(計測開始) - Drag&Drop操作を行う
- コンソールで
Perf.stop()
を実行(計測終了) - コンソールで
Perf.printWasted()
を実行(結果出力)
printWasted()
は仮想DOMの再構築が行われたコンポーネントのうち、レンダリングの必要がなかったものにかかった時間などを表示するメソッドです。これを実行すると以下のようなテーブルが表示されます。
一番上の行を見ると、WidgetContainer > DragSource(DropTarget(WidgetElemControl))
に3秒弱もの時間がかかっています。(「親コンポーネント > 対象コンポーネント」という形式で表示されます。)
このコンポーネントはアイテムコンポーネントをラップしているWidgetElemControl
を、React DnD(というDrag&Drop用ライブラリ)のDropTarget
とDragSource
でさらに二重にラップしたものです。ここを改善すればかなりの効果が見込めそうです。
また、2行目のCustomDragLayer > WidgetElem_ImageSnapDish
はDrag中のアイテムコンポーネントなのですが、Drag操作は1回しかしていないのに840ものインスタンスが更新対象になっていて、ここも最適化の必要がありそうです。
改善1:DragSource(DropTarget(WidgetElemControl))
React DnDのドキュメントを見ると、props
がオブジェクト要素を持っている場合、カスタマイズした比較関数をDragSource()
に渡すことでパフォーマンスを改善できるようです。(ReactコンポーネントのshouldComponentUpdate
に相当)
DragSource
にはWidgetElemControl
に渡される予定のprops
が渡ってくるので、その内容を比較する関数を定義し、DragSource()
に渡します。
function arePropsEqual(nextProps, props) { return ( nextProps.pos === props.pos && nextProps.noHoverMenu === props.noHoverMenu && nextProps.hasPrev === props.hasPrev && nextProps.hasNext === props.hasNext && nextProps.boundary === props.boundary && _.isEqual(nextProps.widget, props.widget) ); }
ビルドしてプロファイリングすると以下のような結果になりました。
Drag操作は手作業で行っているので同じ結果にはなりませんが、DragSource(DropTarget(WidgetElemControl))
の行を見ると、時間・インスタンス数とも10分の1程度になったのが分かります。
ゼロにはなっていませんが、CustomDragLayer > WidgetElem_ImageSnapDish
の方が影響が大きくなったのでそちらに取りかかることにします。
改善2:CustomDragLayer > WidgetElem_ImageSnapDish
CustomDragLayer
はDrag中のアイテムのプレビューを表示するための土台です。その上にDragしたアイテムコンポーネント(今回はWidgetElem_ImageSnapDish
)を描画することでプレビューを実現しています。
WidgetElem_ImageSnapDish
にshouldComponentUpdate
を定義すればいいのですが、このWidgetElem_*
というコンポーネントはアイテムの種類だけ存在しているため(現在18種)、以下のようなMixinを作って各アイテムコンポーネントに適用します。
import _ from 'lodash'; export default { shouldComponentUpdate(nextProps, nextState) { return ( !_.isEqual(nextProps, this.props) || !_.isEqual(nextState, this.state) ); } };
再度プロファイリングを行うと以下のような結果になりました。
見事CustomDragLayer > WidgetElem_ImageSnapDish
がなくなっています。Drag中の動きもだいぶ滑らかになりました。
こうなると、先ほどやり残したDragSource(DropTarget(WidgetElemControl))
が気になるので、そちらの解析に戻ります。
改善3:DragSource(DropTarget(WidgetElemControl)) の仕上げ
DragSource
は改善済みなので次はDropTarget
ですが、props
の比較はすでにDragSource
で行っているので何もやれることがありません。
最後のWidgetElemControl
にはshouldComponentUpdate
がないのでこれを定義してみます。
shouldComponentUpdate(nextProps, nextState) { return ( nextState.showControl !== this.state.showControl || nextState.showMenu !== this.state.showMenu || nextProps.isDragging !== this.props.isDragging || !arePropsEqual(nextProps, this.props) ); },
結果は、、、何一つ改善されていませんでした。
props
とstate
に変更がなければ更新しないのに、それでも差分なしの「空更新」が行われているということは、レンダリングに影響しないprops
があるということです。(state
は影響があるものしか比較していないため)
arePropsEqual
を見直してみると、アイテムの位置を表すpos
というプロパティを比較していました。Drag中はマウスポインタの座標に応じてアイテムの位置を入れ替えているのですが、位置が変わってもアイテムの見た目は変わらないため空更新が起きていたようです。pos
の比較を行わないようにして再度プロファイリングを行ってみたところ、ほぼ空更新はなくなりました。(ゼロにはなりませんでしたが、条件が複雑そうなのでこれ以上の調査は行いませんでした。)
function arePropsEqual(nextProps, props) { return ( - nextProps.pos === props.pos && nextProps.noHoverMenu === props.noHoverMenu && nextProps.hasPrev === props.hasPrev && nextProps.hasNext === props.hasNext && nextProps.boundary === props.boundary && _.isEqual(nextProps.widget, props.widget) ); }
まとめ
- ボトルネックは
react-addons-perf
で調べよう shouldComponentUpdate
を定義して、更新の必要がなければfalse
を返そう- レンダリングに関係ない
props
に気をつけよう