zenpachi blog

React/ReduxによるWebアプリのパフォーマンスチューニング

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

React/Reduxで作られたWebアプリが表示データの増加とともにパフォーマンスが落ちてきているということで、パフォーマンスチューニングを行う機会があったので対応内容を残しておきます。
内容は主にコンポーネントの再レンダリングの扱いになります。

基本的な内容のため今更感はありますが参考までに。

実行環境

  • react 16.8.1
  • redux 4.0.1

コンポーネント

以下はチューニング前の状態を適当に簡略化したもの。 リストを選ぶと背景に色がつくコンポーネントです。

const List = () => {
  const [items, setItems] = useState(() => (
    // ダミーデータ
    Array.apply(0, Array(50))
      .map((x, y) => ({
        id: y,
        title: y,
        flag: false,
      }))
  ));

  const onClick = useCallback(id => setItems(
    prevItems => prevItems.map(item => (
      id === item.id ? { ...item, flag: !item.flag } : item
    )),
  ), []);

  return (
    <>
      <p>{`選択されているリスト: ${items.filter(item => item.flag).map(item => item.id).toString()}`}</p>
      <ul>
        {items.map(item => (
          <Item key={item.id} item={item} onClick={onClick} />
        ))}
      </ul>
    </>
  );
};

const Item = ({
  item: { id, title, flag },
  onClick,
}) => (
  <li className={`${style.item} ${flag && style['is-selected']}`}>
    <button
      type="button"
      onClick={() => onClick(id)}
    >
      {title}
    </button>
  </li>
);

実行するとこんな感じ。

実行結果

このくらいであれば特に知覚できるほどの問題はないのですが、
データを10,000程度まで増やすとこんな感じとなります。

実行結果

console画面にはItemがクリックされたタイミングでclickと、描画完了したタイミングでかかった時間を表示していますが、自分の環境ではクリックから描画完了までに500〜600ms程度とかなり時間がかかっていることがわかります。
(処理時間は実行環境によってかなり変化します。また、上記キャプチャはPerformanceObserverにて{entryTypes: ['measure']}をobserveし、entry.nameが'⚛ (React Tree Reconciliation: Completed Root)'であるentryのdurationを描画完了時間としています。実際の画面上の描画にはJSでの処理以外にもreflow, repaintなどが発生していることや、clickしたタイミングと計測開始時間が多少ことなることから正確には描画完了までの時間とは5~15ms程度異なりますが、ここでは便宜上「描画完了」と呼ぶことにします。)

これはItemがクリックされるたびに全て(10,000個)のItemコンポーネントが再レンダリングされているためです。
(そもそもデータが多すぎるので本来はreact-virtualizedなどを使って必要なItemのみを画面に表示するべきですが、今回は全て表示する想定で実装してみます。)

これらを解消するためにはいくつかの方法があるのでそれらを試していきましょう。

React.memo

React.memoを使用するとpropsをshallow equalで比較して再レンダリングを制御します。
つまりpropsに変更があった場合のみにレンダリングをするようになります。
使い方は簡単で、componentをwrapするだけです。

const Item = React.memo(({
  item: { id, title, flag },
  onClick,
}) => (
  // 先ほどと同様のため省略
));

このようにして実行してみると

実行結果

200〜250msほどで処理が完了しており、最初の状態と比較して半分ほどの時間で描画できるようになりました。

しかし、React.memoはこのように非常に便利なものではありますが、shallow equalでの比較を行なっているため、propsがオブジェクトの場合に、値が全て同じでも参照が異なっていると再レンダリングされてしまうことに注意が必要です。

試しにList.jsxのonClickを以下のように変更してみます。

// prevItems => prevItems.map(item => (
//   id === item.id ? { ...item, flag: !item.flag } : item
// )),
//    ↓
prevItems => prevItems.map(item => (
  { ...item, flag: id === item.id ? !item.flag : item.flag }
)),

すると、

実行結果

propsのitemが常に新しいオブジェクトとなるため、Itemが全て再レンダリングされてしまううえに、memo化されたコンポーネントは渡されたpropsの比較を行うため、元よりもさらに遅くなってしまいました。

React.memoの第2引数

React.memoは第2引数を取ることができ、より詳細に再レンダリングの条件を指定できます。
第2引数に渡す関数は引数として前回のpropsと新しいpropsをとります。
試しに実装してみます。

先ほどと同じくItemに渡すpropsのオブジェクトは毎回新しく生成するようにしたままです。

// 一部抜粋
prevItems => prevItems.map(item => (
  { ...item, flag: id === item.id ? !item.flag : item.flag }
))

Itemのmemo()に第2引数を渡します。

const Item = React.memo(({
  item: { id, title, flag },
  onClick,
}) => (
  <li className={`${style.item} ${flag && style['is-selected']}`}>
    <button
      type="button"
      onClick={() => onClick(id)}
    >
      {title}
    </button>
  </li>
), (prevProps, nextProps) => prevProps.item.flag === nextProps.item.flag);

今回はitem.flag以外は変更しない前提なのでflagのみを比較します。
第2引数の関数は再レンダリングをしない場合はtrue、する場合はfalseを返すようにします。
これは後述するclass componentのshouldComponentUpdateとは逆なので注意します。

実行結果

無事再レンダリングを制御することができました。

class component

ここまではfunctional componentを対象にしていましたがclass componentの場合についても対応します。
Itemをclass componentで書くとこのようになります。

class Item extends React.Component {
  render() {
    const {
      item: { id, title, flag },
      onClick,
    } = this.props;
    return (
      <li className={`${style.item} ${flag && style['is-selected']}`}>
        <button
          type="button"
          onClick={() => onClick(id)}
        >
          {title}
        </button>
      </li>
    );
  }
}

shouldComponentUpdate

ReactのcomponentはshouldComponentUpdateというメソッドを実行することで再レンダリングするかどうかを決定しています。 trueを返すと再レンダリングし、falseを返すと再レンダリングしません。
shouldComponentUpdateはデフォルトでは常にtrueを返すようになっているため常に再レンダリングを行います。
このshouldComponentUpdate内で古いpropsと新しいpropsを比較することで再レンダリングをコントロールすることができます。

class Item extends React.Component {
  shouldComponentUpdate(nextProps) {
    const { item: prevItem } = this.props;
    const { item: nextItem } = nextProps;
    return prevItem.flag !== nextItem.flag;
  }

  render() {
    const {
      item: { id, title, flag },
      onClick,
    } = this.props;
    return (
      <li className={`${style.item} ${flag && style['is-selected']}`}>
        <button
          type="button"
          onClick={() => onClick(id)}
        >
          {title}
        </button>
      </li>
    );
  }
}

実行結果

全てを再レンダリングするよりは早いですが、React.memoと比べるとやや遅い結果となりました。
shouldComponentUpdateそのもののコストが低くなさそうなので、頻繁に再レンダリングが必要なcomponentでは使わずにそのままレンダリングしてしまったほうが良いかもしれません。

React.PureComponent

React.PureComponentはshallow equalで古いporpsと新しいpropsを比較して再レンダリングの要否を決定するcomponentです。
shallow equalという部分に注意すればshouldComponentUpdateを自分で記述するよりも簡単です。

実行結果

測定結果も150〜200msと速いようです。

stateの構造を変更する

現時点ではListは全てのItemの情報をitemsというstateで持っており、Itemがclickされるとループを実行してflagを変更します。
この処理はclickによって1つのItemのflagを変更したいだけなのに対して、全てのItem分のループを実行するので無駄が多いです。

選択されているItemのidのみを持つstateを別に作ってみます。

const List = () => {
  const [items, setItems] = useState(() => (
    Array.apply(0, Array(10000))
      .map((x, y) => ({
        id: y,
        title: y,
      }))
  ));
  const [selectedItems, setSelectedItems] = useState({});

  const onClick = useCallback((id) => (
    setSelectedItems(prevSelectedItems =>
      Object.assign({}, prevSelectedItems, { [id]: !prevSelectedItems[id] }));
  ), []);

  return (
    <>
      <p>
        {`選択されているリスト: ${Object.keys(selectedItems).filter(key => selectedItems[key]).toString()}`}
      </p>
      <ul>
        {items.map(item => (
          <Item key={item.id} item={item} selectedFlag={selectedItems[item.id]} onClick={onClick} />
        ))}
      </ul>
    </>
  );
};
class Item extends React.PureComponent {
  render() {
    const {
      item: { id, title },
      selectedFlag,
      onClick,
    } = this.props;
    return (
      <li className={`${style.item} ${selectedFlag && style['is-selected']}`}>
        <button
          type="button"
          onClick={() => onClick(id)}
        >
          {title}
        </button>
      </li>
    );
  }
}

実行結果

こちらは残念ながら多少早くなったかな?程度で知覚できるほどの速度の向上は見られませんでした。
そもそも処理にかかっている時間の多くは10,000個のItemを再レンダリングが必要か判定している部分であり、clickに伴うstateの更新にはほとんど時間がかかっていません。
操作に対して新たなstateを作成する部分の処理が高コストである場合は効果がでるのではないかと思いますが、今回のサンプル程度の低コストな処理であればパフォーマンスへの影響はそれなりに小さいと考えて良さそうです。

Redux

Reduxを使用している場合のパフォーマンスについても対応してみます。
ディレクトリ構成はこんな感じ。

src
├── actions
│   └── index.jsx
├── components
│   ├── App.jsx
│   ├── Item.jsx
│   └── List.jsx
├── containers
│   ├── Item.jsx
│   └── List.jsx
├── index.jsx
├── reducers
│   └── index.jsx
└── store
    └── configureStore.js
export const TOGGLE_ITEM = 'TOGGLE_ITEM';

export const toggleItem = id => ({
  type: TOGGLE_ITEM,
  id,
});
import { combineReducers } from 'redux';
import { TOGGLE_ITEM } from '../actions';

const initialItems = Array.apply(0, Array(10000))
  .map((x, y) => ({
    id: y,
    title: y,
    flag: false,
  }));

const items = (state = initialItems, action) => {
  switch (action.type) {
    case TOGGLE_ITEM:
      return state.map(item => (
        action.id === item.id ? { ...item, flag: !item.flag } : item
      ));
    default:
      return state;
  }
};

const rootReducer = combineReducers({
  items,
});
export default rootReducer;

import { connect } from 'react-redux';

import List from '../components/List';

const mapStateToProps = (state) => {
  const { items } = state;
  return {
    items,
  };
};

export default connect(
  mapStateToProps,
)(List);
import React from 'react';
import Item from '../containers/Item';

const List = ({ items }) => (
  <>
    <p>{`選択されているリスト: ${items.filter(item => item.flag).map(item => item.id).toString()}`}</p>
    <ul>
      {items.map(item => (
        <Item key={item.id} item={item} />
      ))}
    </ul>
  </>
);

export default List;
import { connect } from 'react-redux';

import Item from '../components/Item';
import { toggleItem } from '../actions/index';

const mapStateToProps = (state, ownProps) => {
  const { id, title, flag } = ownProps.item;
  return {
    id,
    title,
    flag,
  };
};

const mapDispatchToProps = dispatch => ({
  onClick: id => dispatch(toggleItem(id)),
});

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(Item);
import React from 'react';

const Item = ({
  id,
  title,
  flag,
  onClick,
}) => (
  <li className={`${style.item} ${flag && style['is-selected']}`}>
    <button
      type="button"
      onClick={() => onClick(id);}
    >
      {title}
    </button>
  </li>
);

export default Item;

Reduxアーキテクチャになったことでファイルは増えましたが基本的な仕組みはReactの初期状態と変えていません。 こちらを実行すると下記のようになります。

実行結果

React.memoやPureComponentを使用していないにも関わらず素のReactの場合よりも処理が早くなっています。 これはReduxのconnectを使用すると、渡されるpropをshallow equalで比較して再レンダリングが必要かどうかを判定するコンポーネントを生成するためです。 つまり今回の例でいうとcomponents/Item.jsxがPureComponentと同様な振る舞いをするようになっており、変更が必要なItemのみが再レンダリングされているためです。

stateの構造を変更する

上述の通りReduxのconnectで生成されたコンポーネントは渡されるpropを比較して再レンダリングを判断します。
つまりそのコンポーネントにとって必要のないpropを渡してしまうと不必要なレンダリングが行われてしまいます。Reduxを使用することでpropsを親から子へとバケツリレーする必要がなくなるため、必要なpropsのみを渡すように変更してみます。

今回の例ではListコンポーネントにはItemが選択されているかどうかの情報は必要ないため、素のReactの時と同様stateの構造を変更します。

import { combineReducers } from 'redux';
import { TOGGLE_ITEM } from '../actions';

const initialItems = Array.apply(0, Array(10000))
  .map((x, y) => ({
    id: y,
    title: y,
  }));

const items = (state = initialItems) => state;

const selectedItems = (state = {}, action) => {
  switch (action.type) {
    case TOGGLE_ITEM:
      return Object.assign({}, state, { [action.id]: !state[action.id] });
    default:
      return state;
  }
};

const rootReducer = combineReducers({
  items,
  selectedItems,
});
export default rootReducer;
import React from 'react';
import SelectedItems from '../containers/SelectedItems';
import Item from '../containers/Item';

const List = ({ items }) => (
  <>
    <SelectedItems />
    <ul>
      {items.map(item => (
        <Item key={item.id} item={item} />
      ))}
    </ul>
  </>
);

export default List;
import { connect } from 'react-redux';

import Item from '../components/Item';
import { toggleItem } from '../actions/index';

const mapStateToProps = (state, ownProps) => {
  const { id, title } = ownProps.item;
  const flag = state.selectedItems[id];
  return {
    id,
    title,
    flag,
  };
};

const mapDispatchToProps = dispatch => ({
  onClick: id => dispatch(toggleItem(id)),
});

export default connect(
  mapStateToProps,
  mapDispatchToProps,
)(Item);
import { connect } from 'react-redux';

import SelectedItems from '../components/SelectedItems';

const mapStateToProps = (state) => {
  const { selectedItems } = state;
  return {
    selectedItems,
  };
};

export default connect(
  mapStateToProps,
)(SelectedItems);
import React from 'react';

const SelectedItems = ({ selectedItems }) => (
  <p>
    {`選択されているリスト: ${Object.keys(selectedItems).filter(key => selectedItems[key]).toString()}`}
  </p>
);

export default SelectedItems;

Itemが選択されているかどうかをselectedItemsというstateに切り出し、Listコンポーネントには渡さないようにしたことでListが再レンダリングされなくなります。
また、選択されているリストのid一覧を更新するためにSelectedItemというコンポーネントを新たに作成し読み込んでいます。

実行結果

配列をループして10,000個のItemコンポーネントの配列を生成しなくなったことで処理が速くなりました。
(SelectedItemコンポーネントの生成コストは新たに発生していますが。)

速くはなったが

RAILというパフォーマンス指標によるとユーザの入力に対するレスポンスは100ms以内に返す必要があります。
10,000件のリストを全て一度に表示するというやや極端な例ではありますが、このくらいの量になると今回の対応方法では100ms以内でのレスポンスはなかなか難しそうです。 (実行する端末によっては100ms以下で実行することもできましたが、実際にユーザが使う環境としてはサンプルくらいのスペックなのではないかと思われます。)

冒頭にも書いたとおり実際のプロダクトを実装する際にはreact-virtualizedなどを使用して一度にレンダリングされるリストの数を制限するなどの対応が一般的かと思いますが、機会があれば今回のように全てのリストを表示する方法のままで100msを切る実装ができないかもう少し試してみようと思います。

追記

続きを書きました。React/ReduxによるWebアプリのパフォーマンスチューニング その2

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