🦈

Reactのこともっとよくしろう! ~Reduxパフォーマンス編~

2021/04/06に公開

この記事は、社内で実施した勉強会の資料ですが、資料のみでも学習できるよう執筆しています。
社内勉強会資料シリーズ第三弾にして、Reactシリーズ最後になります🎉

今回のテーマは、
・Reduxのパフォーマンスチューニングについて(selectorメモ化/正規化)
となります。
ReactといいつつオールRedux関連です。

今回も、リポジトリを用意しました。
https://github.com/takanokana/react-tag
branchをlesson/4に切り替えるとこの記事の作業がすぐに取りかかれる状態となります。

Reduxのパフォーマンスチューニング

Redux公式にパフォーマンス改善にして解説したページがあります。
基本はこの公式に乗っ取って説明を行い、細かい部分を補足するという形で進めます。
https://redux.js.org/tutorials/essentials/part-6-performance-normalization

Reselect

Reduxで実装されるselector
selectorの扱いを復習しつつ、それが引き起こすパフォーマンスの問題を実際に実装しながら確認し、その解決方法であるReselectというライブラリについて解説します。

selectorを実装してみる

用意しているリポジトリのブランチを、lesson/4に切り替えてください。

ポストされた付箋が各色ごとに分類されるという簡易的なアプリですが、
headerに表示しているメッセージを、ローカルのStateからReduxに移行してみましょう。

新たにsliceを作成します。

src/tsx/stores/slices/messageSlice.ts
import { createSlice } from '@reduxjs/toolkit'

const messageSlice = createSlice({
  name: 'message',
  initialState: {
    msgTxt: '魚大好き',
  },
  reducers: {
    changeMsg: (state, action) => {
      state.msgTxt = action.payload
    },
  },
})

export const { changeMsg } = messageSlice.actions

// reducerをexport → storeへ
export default messageSlice.reducer

作成したSliceをrootStateにつなぎます。

src/tsx/stores/index.ts
// import一部省略
+ import messageSlice from './slices/messageSlice'
const store = configureStore({
  reducer: {
    tagList: tagListSlice,
+   message: messageSlice,
  },
})

コンポーネント部分を調整します。

src/tsx/views/components/atoms/HeaderMsg.tsx
 import React, { useState, FC } from 'react'
+import { useDispatch } from 'react-redux'
+
+// store
+import { useSelector } from '../../../stores/index'
+import { changeMsg } from '../../../stores/slices/messageSlice'
 
 export const HeaderMsg: FC = () => {
-  const [message, setMessage] = useState('魚大好き')
+  const dispatch = useDispatch()
+  const { msgTxt } = useSelector((state) => state.message)
+  const changeMsgHandler = () => {
+    dispatch(changeMsg('React大好き'))
+  }
 
   return (
     <>
-      <p>{message}</p>
+      <p>{msgTxt}</p>
       <button
-        onClick={() => setMessage('react大好き')}
+        onClick={changeMsgHandler}
         type="button"
       >
         メッセージを変える

これで準備が一通り整いました!
この状態で、 白一覧のページを開きつつHeaderの「メッセージを変える」ボタンを押し、
再レンダリングされたコンポーネントの計測をPorifleを用いて行いましょう。

Headerが再レンダリングされるのは当然のこととして、TagListまでもが再レンダリングされています。
TagList内ではmessageSliceを扱ってないはずなのに何故なのでしょうか?

なぜselectorで不要なレンダリングが起きるのか

まず、useSelectorの機能についておさらいしておきます。

https://react-redux.js.org/api/hooks

When an action is dispatched, useSelector() will do a reference comparison of the previous selector result value and the current result value. If they are different, the component will be forced to re-render. If they are the same, the component will not re-render.

(おおよその訳)
アクションがdispatchされると、useSelector()は前回のselectorの結果と今回の結果を比較します。もしそれが異なっている場合、再レンダリングが強制され、同じであれば再レンダリングされません。

useSelector() uses strict === reference equality checks by default, not shallow equality .

(おおよその訳)
useSelectorはデフォルトでは厳格参照である===でチェックし、浅い比較は行われません。

これを踏まえて、tagListSliceのselectorを確認してください。

src/tsx/stores/slices/tagListSlice.ts

// 特定の色のタグのみ
export const colorSelect = (color: string, state: RootState) => {
  const tags: tag[] = Object.values(state.tagList.data)
  return tags.filter((thisTag) => thisTag.color === color)
}

dispatchが走るたびに,colorSelect(color, state)は毎度違う参照の配列を返却します。
なせなら、filterは毎度新しい配列を生成するからです。
これはつまり、 厳格チェック === でfalseになってしまい、毎度再レンダリングが強制されてしまうということです。

useSelectorを扱う場合はこの性質に気をつけておかないと今回のケースのように無駄なレンダリングを走らせてしまったり、またmutableで更新してしまって再レンダリングがおきない!といった不具合が発生します。

Reselectを使ってみる

上記のパフォーマンス問題を解決するためには、 colorCountSelectがcolor変更時以外はキャッシュするようにすればいいですね。
そういった実装にうってつけのラリブラリ、Reselectがあります。
しかもRedux-toolkitには最初から同梱されています。便利ですね。
https://github.com/reduxjs/reselect

src/tsx/stores/slices/tagListSlice.ts
-import { createSlice } from '@reduxjs/toolkit'
+import { createSlice, createSelector } from '@reduxjs/toolkit'

// 特定の色のタグのみ
export const colorSelect = createSelector(
  (state: RootState) => state.tagList.data,
  (state: RootState, clrType: string) => clrType,
  (tags, clrType) => tags.filter((thisTag) => thisTag.color === clrType),
)

createReselectorは createSelector(...inputSelectors | [inputSelectors], resultFunc) というインターフェースになっていて、
inputSelectorsはコールバックを指定します。コールバックの第一引数はReduxのState全体が入ってきます。
resultFuncもコールバックですが、引数はinputSelectorsの戻り値がそれぞれ入ってきます。

この状態でProfileで計測確認してみましょう。

TagList以下が無事レンダリングされなくなりました🎉

selectorに関するおまけ話

以前の記事でもお話しましたが、selectorで返却する値は常に必要なものだけ(小さな値)にするようにしましょう。
複数回selectorを呼び出して、結果データを小さくしていきます。
これはReduxのStyle Guideでも強く勧められています。
【参照】公式Redux Style Guide
そうすることで、余計な差分検知を防ぐことができます。

正規化について

正規化といえばDBを連想する人も多いと思います。
データを格納するStoreも同じように正規化することで、パフォーマンスの向上が期待できます。
正規化について書かれた公式Reduxページも用意されています。
https://redux.js.org/recipes/structuring-reducers/normalizing-state-shapes

const blogPosts = [
  {
    id: 'post1',
    author: { username: 'user1', name: 'User 1' },
    body: '......',
    comments: [
      {
        id: 'comment1',
        author: { username: 'user2', name: 'User 2' },
        comment: '.....'
      },
      {
        id: 'comment2',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  },
  {
    id: 'post2',
    author: { username: 'user2', name: 'User 2' },
    body: '......',
    comments: [
      {

上記ページから引用したものですが、
{ username: 'user2', name: 'User 2' }が繰り返されていたり、情報が深くネストされています。
こういった正規化されていないデータでは、以下の点で問題が発生します。

When a piece of data is duplicated in several places, it becomes harder to make sure that it is updated appropriately.
訳)いくつかの場所でデータが重複していると、適切にアップデートを行うのが困難になる。

これはその通りですね。データの同時更新を誤ったり、不整合が起こったりなどが発生しやすくなります。

Nested data means that the corresponding reducer logic has to be more nested and therefore more complex. In particular, trying to update a deeply nested field can become very ugly very fast.
訳)ネストされたデータはそれに対応するレデューサーのロジックも入れ子になり複雑になります。特に、深いネストされたフィールドを更新しようとするとあっという間に汚くなってしまいます。

Since immutable data updates require all ancestors in the state tree to be copied and updated as well, and new object references will cause connected UI components to re-render, an update to a deeply nested data object could force totally unrelated UI components to re-render even if the data they're displaying hasn't actually changed.
訳)イミュータブルなデータ更新のためには状態ツリーの全ての祖先もコピーされて更新されなければなりません。接続されたUIコンポーネントが再レンダリングされるため、深くネストされたデータオブジェクトの更新により、表示されているデータが実際には変更されていなくても、まったく関係のないUIコンポーネントが再レンダリングされる可能性があります。

これは実際にコードを確認するとよりわかりやすいかと思います。

  reducers: {
    commentChange: (state, action) => {
     state.forEach((post, pi) => {
	     if(post.id !== state.payload.id) return 
	     state[pi].comments.forEach((comment,ci) => {
	        if(comment.id !== state.payload.commentId) return 
		state[pi].comment[ci].comment = state.payload.comment
	     })
      })
    },
  },

うわー! コメントを更新したいだけなのにわかりづらいですね。
そしてstateは更新前と更新後で全く新しいオブジェクトになっています(immerのせいでわかりづらいかもしれません)。これはつまり、この状態ツリーを参照している全てのDOMが再レンダリングされることを意味します。

緑のコンポーネントの再レンダリングのように無駄がたくさんできてしまいます。
これらが正規化されていないStoreの弊害です。

ここでStoreの正規化された状態についての定義を確認します。

  • データに重複がないこと。
  • 正規化されたデータはIDがkeyに、アイテム自体がvalueとなるlookupテーブルに保持される。
  • 特定のアイテムのタイプに対して全てのIDの配列がある場合がある。

lookupテーブルとは、端的に言ってしまうとオブジェクト(連想配列)のことです。他の言語ではdictionaries maps などと呼ばれています。

{
  users: {
    ids: ["user1", "user2", "user3"],
    entities: {
      "user1": {id: "user1", firstName, lastName},
      "user2": {id: "user2", firstName, lastName},
      "user3": {id: "user3", firstName, lastName},
    }
  }
}

これの何が嬉しいかというと、ループを回さずして欲しい情報にアクセスできる、という点です。
IDさえわかっていればforEachやfilterを使わずとも、
state.users.entities['user1']で、好きな情報にアクセスできます。
これは記述がスッキリするのみでなく、パフォーマンス面においても効果を発揮します。
ビッグ・オー記法で説明するなら、配列を用いての探索は線形探索のためO(n)ですが、
連想配列の場合はハッシュを利用するので平均O(1)で済みます。

createEntityAdapterを使う

{ ids: [], entities: {} } という形にsliceのデータを加工してくれるcreateEntityAdapterというAPIがRedux Toolkitから提供されています。
それだけでなく、追加や削除や更新など、よくあるreducer関数が初めから組み込まれています。
createEntityAdapterから生成したadapterは他にもよく使うであろう関数が用意されています。

  • getSelectors・・・ いくつかの便利なselectorを生成できる
  • getInitialState・・・ {ids:[], entities: {}} 初期化に便利

では早速 createEntityAdapter を用いてプロジェクトをリファクタしてみます。

src/tsx/stores/slices/tagListSlice.ts

-import { createSlice, createSelector } from '@reduxjs/toolkit'
+import {
+  createSlice, createSelector, createEntityAdapter,
+} from '@reduxjs/toolkit'
 import { RootState } from '../index'
 
-interface tag {
+type Tag = {
   color: string,
   text: string,
   id: number
 }

-const data: tag[] = []
-for (let i = 0; i < 100; i++) {
-  data.push({
-    color: 'white',
-    text: '夜ネギを買う',
-    id: i,
-  })
-}
+const tagsAdapter = createEntityAdapter<Tag>()

 const tagListSlice = createSlice({
   //   slice名
   name: 'tagList',
   //   初期値
-  initialState: {
-    nextId: 1,
-    data,
-  },
+  initialState: tagsAdapter.getInitialState(),
   // 各reducer 第一引数でstate情報を受け取り、第二引数でユーザーが操作した情報を受け取る
   reducers: {
-    addTag: (state, action) => {
-      state.data = [
-        ...state.data,
-        {
-          ...action.payload,
-          id: state.nextId,
-        },
-      ]
-      state.nextId += 1
-    },
+    addTag: tagsAdapter.addOne,
   },
 })

// 一部省略
+const { selectTotal } = tagsAdapter.getSelectors()
 // 特定の色のタグのみ
 export const colorSelect = createSelector(
-  (state: RootState) => state.tagList.data,
+  (state: RootState) => state.tagList.entities,
   (state: RootState, clrType: string) => clrType,
-  (tags, clrType) => tags.filter((thisTag) => thisTag.color === clrType),
+  (entities, clrType) => Object.keys(entities)
+    .filter((value) => entities[value]?.color === clrType)
+    .map((value) => entities[value]),
 )
 
+export const totalSelect = selectTotal

変更箇所が多いのでゆっくりみていきます。

const tagsAdapter = createEntityAdapter<Tag>() でadapterを作成しています。
そして、初期値は先ほど解説したgetInitialStateが用いられているので、初期値は{ids:[], entities: {}}ですね。
reducer/actionの生成はもはやadapter組み込みのもので事足ります。
addOneは要素を一つ追加するアクションです。
CRUDの様々な便利なメソッドの一覧をみたい場合は、Redux-toolkitの公式を確認してください。

selectTotalは要素の総数を取得できるselectorです。
こちらも他にもいくつか便利な組み込みselectorが用意されています。

src/tsx/views/pages/TagList.tsx
 // store
 import { useSelector } from '../../stores/index'
-import { colorSelect } from '../../stores/slices/tagListSlice'
+import { colorSelect, totalSelect } from '../../stores/slices/tagListSlice'
 // style
 import { colors } from '../../style/components/atoms/Button'

// 一部略
export const TagList: FC<ItagList> = ({ clrType }) => {
  const data = useSelector((state) => colorSelect(state, clrType))
+ const total = useSelector((state) => totalSelect(state.tagList))
  const tagListItem = ({ index, style }) => (

// 一部略
     <main>
       <List
         height={500}
-        itemCount={data.length}
+        itemCount={total}


先ほど作成したselectorに合わせてデータ形式を整えています。

src/tsx/views/pages/top/PostBox.tsx
import React, {
  useState, useRef, FC,
} from 'react'
+import { nanoid } from '@reduxjs/toolkit'
import { useDispatch } from 'react-redux'
import { css } from '@emotion/core'

// 一部省略
  const postHandler = () => {
    if (ref.current === null) return
    dispatch(addTag({
+      id: nanoid(),
      color,
      text: ref.current.value,
    }))

nanoidはredux-toolkitに初めから組み込まれている関数で、
暗号化されていないランダムなIDを生成してくれます。
詳しく調べたい方は
【参照】 redux-toolkit公式 ,コピー元となるai/nanoidのGitHub
を確認ください。
Redux-toolkitは何気に便利な機能がたくさん入っているので活用していきましょう。

ちなみに、entryAdapterを使う場合はidが必須となります。idをつけることで自動で、
例) id = 1の場合

1: {
  id:  1,
  entity: object
 }

という正規化された状態で格納してくれます。

これで一通りのリファクタは完了です。
動作が問題ないことが確認できるかと思います🍕

Normalizrを使う

createEntityAdapterと似たようなもので、 Normalizrという正規化をアシストしてくれるライブラリも存在します。
【参考】 paularmstrong/normalizr
リンク先のドキュメント通りですが、

{
  "id": "123",
  "author": {
    "id": "1",
    "name": "Paul"
  },
  "title": "My awesome blog post",
  "comments": [
    {
      "id": "324",
      "commenter": {
        "id": "2",
        "name": "Nicole"
      }
    }
  ]
}

これを

{
  result: "123",
  entities: {
    "articles": {
      "123": {
        id: "123",
        author: "1",
        title: "My awesome blog post",
        comments: [ "324" ]
      }
    },
    "users": {
      "1": { "id": "1", "name": "Paul" },
      "2": { "id": "2", "name": "Nicole" }
    },
    "comments": {
      "324": { id: "324", "commenter": "2" }
    }
  }
}

こうしてくれます。
idで連想配列をとる/entitiesをとるなど、形式はかなりentityAdapterと似ていますね。
深い階層をサクッと正規化できる優れものです。
redux-toolkitを使用していない場合に検討されると良いかと思います。

まとめ

Reduxのパフォーマンスチューニングにおいて不可欠な
・selectorのメモ化
・storeの正規化
について見てきました。
reduxやredux-toolkitのドキュメントは非常に丁寧に書かれており、
関連する記事も沢山あるので、もっと詳細に知りたい方は確認してみましょう!🔍

一旦React勉強会シリーズはここまでとし、
次回以降の勉強会のテーマは模索中です...

Discussion