zenpachi blog

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

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

前回(React/ReduxによるWebアプリのパフォーマンスチューニング)のサンプルを元にさらなるパフォーマンスアップを行う方法を検討します。

目標はRAILの指標であるレスポンス100ms以内です。

前回のサンプルの最終的な状態は以下のようになりました。

src
├── actions
│  └── index.jsx
├── components
│  ├── App.jsx
│  ├── Item.jsx
│  ├── List.jsx
│  └── SelectedItems.jsx
├── containers
│  ├── Item.jsx
│  ├── List.jsx
│  └── SelectedItems.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,
  }));

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 { 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 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 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;
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コンポーネントはconnectされていることでpropsをshallow equalで比較し、変更がない場合は再レンダリングを抑制しているのですが、逆に言えばstateが変更されるたびに全てのItemの再レンダリング制御が実行されてしまっています。

Reduxの思想としてはrootのstoreで全てのstateを管理し、それに従ってコンポーネント群がレンダリングされるというのは正しいかと思うのですが、パフォーマンスを考えると今回のような大量のリストがある場合にはボトルネックになってしまっています。
(処理時間は実行環境によってかなり変化します。また、前回と同様に上記キャプチャはPerformanceObserverにて{entryTypes: ['measure']}をobserveし、entry.nameが'⚛ (React Tree Reconciliation: Completed Root)'であるentryのdurationを描画完了時間としています。実際の画面上の描画にはJSでの処理以外にもreflow, repaintなどが発生していることや、clickしたタイミングと計測開始時間が多少ことなることから正確には描画完了までの時間とは5~15ms程度異なりますが、ここでは便宜上「描画完了」と呼ぶことにします。)

今回のサンプルの場合Itemをクリックした場合に表示の変化があるのは(Item群のなかでは)クリックしたItemだけであることが明らかなので、stateを変更した時に対象のItem以外ではレンダリング制御が実行されないように変更してみます。

import { connect } from 'react-redux';

import List from '../components/List';
import { toggleItem } from '../actions';

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

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

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

const List = React.memo(({ items, selectedItems, onClickItem }) => (
  <>
    <SelectedItems />
    <ul>
      {items.map(item => (
        <Item
          key={item.id}
          item={item}
          flag={selectedItems[item.id]}
          onClick={onClickItem} /> // ④
      ))}
    </ul>
  </>
), (prevProps, nextProps) => {
  const { items: prevItems } = prevProps;
  const { items: nextItems } = nextProps;
  return prevItems.every((prevItem, i) => prevItem.id === nextItems[i].id); // ⑤
});

export default List;
import React, { useState } from 'react';

const Item = ({
  item: {
    id,
    title,
  },
  flag,
  onClick,
}) => {
  const [selected, setSelected] = useState(flag); // ②

  return (
    <li className={`${style.item} ${selected && style['is-selected']}`}>
      <button
        type="button"
        onClick={() => {
          setSelected(!selected);
          setTimeout(() => onClick(id), 1); // ③
        }}
      >
        {title}
      </button>
    </li>
  );
};

export default Item;

まずcontainers/Item.jsxを削除し、Listコンポーネントから直接Itemコンポーネントを呼び出します。これによってrootのstateが変化するたびにcontainers/Item内のmapStateToPropsや再レンダリング制御の比較処理が10,000回実行されることなく、Listコンポーネントを再レンダリングしなければItemコンポーネントも再レンダリングされないようになりました。( ① )

そのままだとItemコンポーネントは表示が変更されませんので、React Hooksを使用してローカルstateを持つようにし、ローカルstateの更新で表示を制御します。( ② )
こうすることでclick処理に対し最低限の処理でItemの再レンダリングが可能になります。
選択されたItemの一覧情報はSelectedItemコンポーネントでも必要になるため、ローカルstateの更新後にrootのstoreにも更新を通知します。( ③ )

今回のサンプルだけで言えば最初は選択されているItemが無い想定なので、selectedItemsをpropsとしてListに渡す必要はないのですが、最初から選択されたItemがある場合も考慮しListへはpropsを渡すようにし、Itemに選択状態の初期値として渡すようにします。( ④ )
そうするとselectedItemsのstateが更新されるたびにListコンポーネントは再レンダリングを行ってしまうようになるため、React.memoの第2引数を使用しItemのidが全て同じであればListは再レンダリングを行わないようにしています。( ⑤ )

これを実行すると以下のようになりました。

実行結果

描画が2度行われているのは、ローカルstateが更新されてItemが描画された後にrootのstoreのstateが更新されてSelectedItemコンポーネントが再描画されているためです。

無事100ms以内でのレスポンスを実現することができました!

Itemを追加してみる

実際のプロダクトではリストが変更されるケースも多いかと思いますので機能を追加してみます。

ボタンをクリックするとListの一番上にItemを追加します。

// 追加
export const addItem = (id, title) => ({
  type: ADD_ITEM,
  id,
  title,
});
// const items = (state = initialItems) => state;
// ↓ 変更
const items = (state = initialItems, action) => {
  switch (action.type) {
    case ADD_ITEM:
      return [...[{ id: action.id, title: action.title }], ...state];
    default:
      return state;
  }
};
// mapDispatchToProps内にaddItemメソッドを追加
const mapDispatchToProps = dispatch => ({
  onClickItem: id => dispatch(toggleItem(id)),
  addItem: () => {
    const id = Math.random();
    const title = '追加されたItem';
    dispatch(addItem(id, title));
  },
});
const List = React.memo(({
  items, selectedItems, onClickItem, addItem,
}) => (
  <>
    <SelectedItems />
    {/*ボタンを追加*/}
    <button type="button" onClick={addItem}>リストを追加</button>
    <ul>
      {items.map(item => (
        <Item key={item.id} item={item} flag={selectedItems[item.id]} onClick={onClickItem} />
      ))}
    </ul>
  </>
), (prevProps, nextProps) => {
  const { items: prevItems } = prevProps;
  const { items: nextItems } = nextProps;
  return prevItems.every((prevItem, i) => prevItem.id === nextItems[i].id);
});

export default List;

実行してみます。

実行結果

items stateが変更されたことでListコンポーネントの再レンダリングが実行され、全てのItemコンポーネントが再レンダリングされるため処理にかなり時間がかかっていることがわかります。

Itemが追加されたとき、既存の各Itemは再レンダリングされる必要はないためReact.memoを使用してレンダリングを抑制します。

const Item = React.memo(({/*省略*/}) => {
  // 省略
});

実行結果

再レンダリングされなくなったことで100msは切れなかったもののかなり描画が速くなりました。

今回の例であればItemは一度描画してしまえば、rootのstateの変化による再レンダリングは必要ないためReact.memoの第2引数で常にtrueを返すことでさらに描画は速くなります(自分の環境で10〜15ms程度)。
しかし、そうすると他のコンポーネントなどからItemの状態を変更することができなくなることと、パフォーマンスへの影響が比較的小さいため、現実的にはそのような実装は行わないことが多いでしょう。

結果

ということで今回は10,000個のリストを全て表示しつつ、(自分の環境では)100ms以内のレスポンスでリストの選択/非選択制御ができました。

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