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

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

このエントリーをはてなブックマークに追加

技術推進室の色川です。

ここ一年くらいランキングシェアというキュレーションメディアのフロントエンドを担当しています。その記事作成画面をReactで実装しており、先日アイテムの移動をDrag&Dropで行えるようにしたのですが、動きはガクガク、ブラウザは途中で固まる、という有様で、パフォーマンスチューニングを余儀なくされたのでその内容を紹介します。

[ランキングシェアの記事作成画面] dnd.png ※画面は開発中のものです。(動画にしたら?との意見があったのですが、Windowsで画面を録画する方法が分からず…)

Reactのパフォーマンス改善の基本

Reactはコンポーネントをツリーとして管理していて、あるコンポーネントの更新が必要になるとそのコンポーネントをルートとするサブツリー全体を更新します。

TechTalk_BAnderson_11052014_Image7.png 引用元: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');

準備が整ったら以下の手順でプロファイリングを行います。

  1. コンソールでPerf.start()を実行(計測開始)
  2. Drag&Drop操作を行う
  3. コンソールでPerf.stop()を実行(計測終了)
  4. コンソールでPerf.printWasted()を実行(結果出力)

printWasted()は仮想DOMの再構築が行われたコンポーネントのうち、レンダリングの必要がなかったものにかかった時間などを表示するメソッドです。これを実行すると以下のようなテーブルが表示されます。

wasted1.png

一番上の行を見ると、WidgetContainer > DragSource(DropTarget(WidgetElemControl))に3秒弱もの時間がかかっています。(「親コンポーネント > 対象コンポーネント」という形式で表示されます。)
このコンポーネントはアイテムコンポーネントをラップしているWidgetElemControlを、React DnD(というDrag&Drop用ライブラリ)のDropTargetDragSourceでさらに二重にラップしたものです。ここを改善すればかなりの効果が見込めそうです。

また、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)
    );
}

ビルドしてプロファイリングすると以下のような結果になりました。

wasted2.png

Drag操作は手作業で行っているので同じ結果にはなりませんが、DragSource(DropTarget(WidgetElemControl))の行を見ると、時間・インスタンス数とも10分の1程度になったのが分かります。
ゼロにはなっていませんが、CustomDragLayer > WidgetElem_ImageSnapDishの方が影響が大きくなったのでそちらに取りかかることにします。

改善2:CustomDragLayer > WidgetElem_ImageSnapDish

CustomDragLayerはDrag中のアイテムのプレビューを表示するための土台です。その上にDragしたアイテムコンポーネント(今回はWidgetElem_ImageSnapDish)を描画することでプレビューを実現しています。

WidgetElem_ImageSnapDishshouldComponentUpdateを定義すればいいのですが、このWidgetElem_*というコンポーネントはアイテムの種類だけ存在しているため(現在18種)、以下のようなMixinを作って各アイテムコンポーネントに適用します。

import _ from 'lodash';

export default {
    shouldComponentUpdate(nextProps, nextState) {
        return (
            !_.isEqual(nextProps, this.props) ||
            !_.isEqual(nextState, this.state)
        );
    }
};

再度プロファイリングを行うと以下のような結果になりました。

wasted3.png

見事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)
        );
    },

結果は、、、何一つ改善されていませんでした。

propsstateに変更がなければ更新しないのに、それでも差分なしの「空更新」が行われているということは、レンダリングに影響しない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)
     );
 }

wasted4.png

まとめ

  • ボトルネックはreact-addons-perfで調べよう
  • shouldComponentUpdateを定義して、更新の必要がなければfalseを返そう
  • レンダリングに関係ないpropsに気をつけよう

名無しのエンジニア
MySQLでカジュアルにズンドコキヨシ
ギョーザエンジニアはじめます