2022年9月2日金曜日

重たい処理を軽く出来るRedux Toolkitを使おう!

https://tech.aptpod.co.jp/entry/2020/06/26/090000 



  

f:id:aptpod-tetsu:20200626111708p:plain

はじめまして!WEBチームの黒川と申します!昨年7月にaptpodに入りましてもうすぐaptpod歴1年になります! aptpodでは主にフロントエンドエンジニアとしてReact/TypeScriptを用いて、お客様向けアプリケーションのUI部分を実装しております。

ご存じの方も多いように、Reactの状態管理にはいくつか方法があり、何を用いるべきかなどでしばしば議論が起こりがちです。代表的なものだけでも、標準APIを用いるuseStateContextやデファクトスタンダードとなってきているRedux、そして新興のRecoilがあります。
弊社のWEBチームではReduxを採用するケースが多いです。私もReduxについては一通りの知識と経験は持っていたつもりだったのですが、先日担当させていただいたプロジェクトで初めてReduxの設計に取り組んだところ、自分がReduxの思想や勘所について何も理解していなかったという事に気づきました。
そこで、同じくReduxの設計で悩んでいる方に向けて少しばかりのヒントになればと思い、私がReduxについて再勉強をする中で見つけた3つのTipsについて本記事にまとめました!(ただし、私自身が大規模開発の経験がないため、本記事は小〜中規模開発向けの内容になります🙇‍♂️)

※本稿のTipsは、公式のRedux Style Guideにも記載があります。Reduxの公式ページは本当にドキュメントが豊富なのでめっちゃ勉強になります。本稿では、抽象度の高い&英語の公式の記載について、なるべく分かりやすくお伝えすることを目的としています。

Redux Toolkitを使おう

Redux ToolkitはRedux開発チームによる公式のツールキットです。Redux開発で困りがちな

  1. イミュータブルな状態の更新
  2. TypeScriptの型定義
  3. 大量のボイラープレート
  4. 非同期処理

あたりが解消され、簡単かつコンパクトに書けるようになります。

特にボイラープレートの削減が個人的に最も嬉しい点です。Reduxを書く時には、ducksパターン、あるいはRe-ducksパターンを用いて書く事が多いのではないかと思います。弊社でもRe-ducksパターンで書くことが多いのですが、Re-ducksパターンは1ディレクトリにボイラープレートとなるファイルが多いため煩雑さと管理の面倒さが生まれます。ducksパターンでは1ファイルにactionやreducerをまとめるため、煩雑さはありません。しかし、開発が進むにつれて多くのactionの数やreducerの処理が増え、1ファイルの持つ情報量が多くなりすぎます。
そこでRedux Toolkitです。以下にincrementdecrementaddValuesubtractValueと4つのactionを持つカウンターのducksパターンについて、普通のReduxとRedux Toolkitを用いた2パターンで記述しました。

// 普通のRedux
const INCREMENT = 'INCREMENT'
const ADD_VALUE = 'ADD_VALUE'
const DECREMENT = 'DECREMENT'
const SUBTRACT_VALUE = 'SUBTRACT_VALUE'

function increment() {
  return { type: INCREMENT }
}

function addValue(value) {
  return { type: ADD_VALUE, payload: value }
}

function decrement() {
  return { type: DECREMENT }
}

function subtractValue(value) {
  return { type: SUBTRACT_VALUE, payload: value }
}

function counter(state = 0, action) {
  switch (action.type) {
    case INCREMENT:
      return state + 1
    case ADD_VALUE:
      return state + action.payload
    case DECREMENT:
      return state - 1
    case SUBTRACT_VALUE:
      return state - action.payload
    default:
      return state
  }
}
// Redux Toolkit
const counterSlice = createSlice({
  name: 'counter',
  initialState: 0,
  reducers: {
    increment: (state) => state + 1,
    addValue: (state, action) => state + action.payload,
    decrement: (state) => state - 1,
    subtractValue: (state, action) => state - action.payload,
  },
})

いかがでしょうか。同じ内容でもRedux Toolkitが提供しているcreateSliceを用いることで記述量がぐっと減りました。このSliceを1ファイルに1つ置いてducksパターンのように用いれば、煩雑さの解消と複雑さの解消が両立できるのではないかと考えています😊もしこのSlice+ducksパターンを用いても1ファイルの持つ情報量が大きく肥大化したのであれば、その時はSliceの責務が大きくなりすぎている可能性がありますので、Sliceの更なる分割を検討してみてもいいかもしれませんね!

以上のようにRedux Toolkitを用いるとReduxのファイル群管理が楽になります。また、前述のようにTypeScriptでちゃんと型定義されているので開発体験が良いことや、あらかじめredux-thunkが組み込まれている点、またredux-devtoolsの設定が既にstoreになされているといった手軽さもあります。(勿論、これらが不要であれば剥がすこともできます!😄)
一方で「immer.jsに依存がある」や「そんなに記述量が減らないじゃん」、「ActionのtypeとActionCreatorが1対1で柔軟性に欠ける」などといった批判があるのも事実です。今の所、私個人としてはこれらのデメリットよりもメリットが上回りオススメしているのですが、開発に用いる際には、チーム内でメリット、デメリットを吟味した上で導入すると良いと思います!

Storeに格納するデータは正規化しよう

状態管理に何を使うにしろAPIからデータを取得することはフロントエンドだと日常的にあることかと思います。
例えば、IoTのサービスで計測データ一覧を取得するAPIであれば、以下のような形のデータがAPIから返ってきます。

const measurements = [
  {
    id: 'measurement1',
    user: { id: 'user1', name: 'HogeHoge' },
    data: [
      {
        id: 'meas1',
        name:'speed',
        value: 100,
        unit: 'km/h',
        time:1234567890
      },
      {
        id: 'meas2',
        name: 'distance',
        value: 1000,
        unit: 'km',
        time:1234567891
      },
      // 似たような個々の計測単位データがずっと続く
    ]
  },
  {
    id: 'measurement2',
    user: { id: 'user2', name: 'FugaFuga' },
    data: [
      {
        id: 'meas3',
        name: 'time',
        value: 10,
        unit: 'seconds',
        time:1234567892
      },
      {
        id: 'meas4',
        name: 'fuel',
        value: 30,
        unit: 'litre',
        time:1234567893
      },
      // 似たような個々の計測単位データがずっと続く
    ]
  }
   // 似たような計測データがずっと続く
]

これはAPIから取得する時はさほど問題になりませんが、Storeに格納する時には以下の理由で問題になります。

  • 各データが散逸して保存されているため、どこかを更新した時に関連する値が全て更新されているか確証を得にくい
  • データが多重ネスト化されていることから、構造が複雑であることに加えてデータの更新に時間がかかる
    • 特に弊社で扱うような計測ではデータは平気で数千、数万以上になりますので、あるidを持つ計測を配列から探してその値の一部を更新して…などといった操作はパフォーマンスを大きく悪化させます
  • イミュータブルなデータの更新はそのデータの全ての親要素の更新も伴います。return {...state, state.hogehoge}state.hogehogeだけではなくマージされたstateも更新されるためです。なので、ネストが深くなれば深くなるほど不必要なデータの更新が起こることになります。

じゃあどうするのかというと、ReduxのStoreをリレーショナルデータベース(以下、DB)のように扱い、格納する値を正規化すれば良いのです。

フロントエンドは普段DBに対してサーバーサイドを通して接しているため、正規化を意識することが少ないです。なので、ここで正規化についておさらいすると、

  • 正規形と呼ばれるルールによりデータの一貫性の維持を実現し、データアクセスの効率化、冗長性と不整合の排除を目的とする
  • 第一正規形〜第五正規形まであるが、多くの場合第三正規形までで実用に足る
    • 第一正規形:各カラムにデータが一つだけ入っており、DBに格納できる状態
    • 第二正規形:各テーブルが部分関数従属な要素を分割して完全関数従属な状態にする
      • 関数従属:ある要素が決まる時に他の要素が決まる時、関数従属となる(例: A -> B)
      • 部分関数従属:複数の候補キーがある中で一部が関数従属の時、部分関数従属となる(例: (A, B) -> C の時に A -> C または B -> Cの場合)
    • 第三正規形: 各テーブルから推移的関数従属を排する
      • 推移的関数従属: 非キー属性同士で関数従属性がある状態(例: A -> B -> C)

以上を踏まえて冒頭のデータを正規化した値が以下になります。

const measurements = {
  measurement1:{
    id:'measurement1',
    userId:'user1',
    data:['meas1','meas2']
  },
  measurement2: {
    id: 'measurement2',
    userId: 'user2',
    data: ['meas3', 'meas4']
  },
}

const users = {
  user1:{
    id:'user1',
    name:'HogeHoge'
  },
  user2:{
    id:'user2',
    name:'FugaFuga'
  }
}

const measurementData = {
  meas1: {
    id: 'meas1',
    name: 'speed',
    value: 100,
    unit: 'km/h',
    time: 1234567890
  },
  meas2: {
    id: 'meas2',
    name: 'distance',
    value: 1000,
    unit: 'km',
    time: 1234567891
  },
  meas3: {
    id: 'meas3',
    name: 'time',
    value: 10,
    unit: 'seconds',
    time: 1234567892
  },
  meas4: {
    id: 'meas4',
    name: 'fuel',
    value: 30,
    unit: 'litre',
    time: 1234567893
  }
}

※dataだけは多対多の関係は中間テーブルよりも配列の方がスマートなためidの配列で表しています

Storeの正規化を行うことで嬉しいポイントとして、以下が挙げられます。

  • Storeの整合性が常に取れている
  • ネストが浅いので複雑性がない。また、値の更新に伴う不必要な値の巻き込み更新が最小限になる
  • それぞれの要素が持つ個別の値について(例: measurementDatameas1など)、filterなどを用いなくてもダイレクトに参照可能であり更新可能である

自分はこの正規化した各要素ごと(usersmeasurementData)に前述のSliceを切ると良い感じにStoreを持てるのではないかと考えています。また、正規化した値を作るのが大変な時は、normalizrのようなライブラリを使うこともReduxは勧めています。実際に、TwitterはStoreへの格納にnormalizrを使用しているそうです。

useSelectorはパフォーマンスを意識しよう

ReduxもHooks時代なのでuseSelectoruseDispatchを用いれば、connectでラップしなくてもReactとReduxの接続が可能となりました。ただし、便利な反面、useSelector は何も考えずに用いるとconnectよりもパフォーマンスが低下します。意識したいベストプラクティスとしては、

  1. useSelectorでオブジェクトを返す時はshallowEqualを併用する
  2. useSelectorを用いる時は大きいオブジェクトを一度に返すのではなく、細かく必要な値ごとに用いる
  3. 重い処理にはreselectを用いる

以下、個別に説明します。

1. useSelectorでオブジェクトを返す時はshallowEqualを併用する

useSelectorは内部にキャッシュを持っていまして、selectorが返す値が前回と等しければキャッシュされた前回の値を返します。ただし、前回の値と新しい値の比較は、===演算子で行われます。ここがミソです。 JavaScript/TypeScriptではプリミティブな値(stringやnumber)であれば、値が等しければ===で比較した時にtrue となりますが、オブジェクト(ObjectやArray)は 値が等しくても===で比較した時に異なるインスタンスであればfalseとなります。以下が例になります。

const a = { val: 'hogehoge' }
const b = { ...a }

// {val: "hogehoge"}
console.log(a)
// {val: "hogehoge"}
console.log(b)

// true
console.log(a === a)
// false
console.log(a === b)

Reduxでは常に値の更新が発生した時にオブジェクトは別のインスタンスとなっています。したがって、selectorから返されるオブジェクトの比較は常にfalseとなり再レンダリングが走ります。また、Containerに接続しているComponentにも異なるオブジェクトが渡されることにより再レンダリングが生じ、これによりパフォーマンスに悪影響が生まれます。
※ちなみにreact-reduxで従来使われていたconnectのmapStateではオブジェクト内の値で比較が行われていたため、この問題は発生しませんでした
※Containerに接続されているComponentはReact.memoでラップされているものと考えます

そこで用いるのがshallowEqualになります。これは、react-reduxで標準に提供されている比較関数でして、これを用いると異なるインスタンスのオブジェクトであっても同じ値であればtrueを返してくれます。(React.memoの比較関数に用いられているものも中身は違うかと思いますがShallow Equalですね!😄)
※比較関数はshallowEqual以外にも任意のものを使用できます
※Shallow Equalは多重ネストまでは比較できないので、先程の正規化がここでも重要になります

import { shallowEqual } from 'react-redux'

const a = { val: 'hogehoge' }
const b = { ...a }

// {val: "hogehoge"}
console.log(a)
// {val: "hogehoge"}
console.log(b)

// true
console.log(shallowEqual(a, a))
// true
console.log(shallowEqual(a, b))

これにより不要な再レンダリングを抑止できて、パフォーマンスの改善に繋がります👌

2. useSelectorを用いる時は大きいオブジェクトを一度に返すのではなく、細かく必要な値ごとに用いる

上述の通り、useSelectorでは渡されたselectorの返り値が異なる場合に再レンダリングが走ります。なので、もし大きなオブジェクトをuseSelectorで返し、その一部をContainerでは使用するという書き方をすると、オブジェクト内の一部の値が別のContainerで変更された場合であっても、値の変更がそれを含むオブジェクトの変更を起こし、接続されたComponentの再レンダリングが生じます。

以下の例で説明しますと、ContainerAではval1val2しか用いていませんが、ContainerBにおいてval3を更新するとStateそのものに更新がかかるためContainerAにも更新がかかります。

type State = {
  val1: string
  val2: string
  val3: string
}

const ContainerA = () => {
  const allState = useSelector((state: State) => state)
  const val1 = useMemo(() => allState.val1)
  const val2 = useMemo(() => allState.val2)
}

const ContainerB = () => {
  const val3 = useSelector((state: State) => state.val3)
}

上記のケースの場合、ContainerAで必要な値の分だけuseSelectorで切り出すと、val1val2はプリミティブな値であるstringなのでuseSelectorの値は変わらず再レンダリングはかかりません。場合によっては、useSelectorからオブジェクトを返す必要もあるかと思いますが、その場合は前述のshallowEqualを用いて可能な限り再レンダリングは避けるようにしましょう。

3. 重い処理にはreselectを用いる

ここまでの説明でuseSelectorを適切に用いることでComponentの再レンダリングを防げることがわかりました。しかし、もう一点考慮すべきポイントがあります。selectorそのものの計算量です。useSelectorはselectorのインスタンスが変わらない限り再計算を行いません。しかし、以下のようにuseSelector内のselector関数がインラインの場合は、毎回レンダリングがかかるたびにselector関数が新しいインスタンスとなるため再計算が行われることとなります。

  const hoge = useSelector(state => state.hogehoge)

ややこしいのですが、useSelectorはactionがdispatchされるたびにselectorの返り値について参照比較が行われ、もし返り値が異なれば再レンダリングをかけます。すなわち以下の例のように、自身は値の変更が生じていないのに、再レンダリングに引っ張られて再計算するパターンがあるのです。

  //どこかでhogeの新しい値がdispatchされる
  dispatch(updateHoge(hoge))

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  // hogeに変更がかかるactionがdispatchされ、当然hogeを変更するため再レンダリングが行われる
  const hoge = useSelector((state) => state.hoge)
  // fugaは変更がないためuseSelectorの返り値は一緒だが、selector関数のインスタンスが新しくなり、再計算が走る
  const heavyFuga = useSelector((state) => heavyCalculation(state.fuga))

軽量なただstateの一部を返すselectorであれば何も問題ありませんが、上記のように重い処理を行っているselectorであればパフォーマンスへの影響が生まれます。このような問題に対処すべく、selectorで何かしらの加工を行うのであればreselectを使用し、selectorの計算もまたメモ化することが好ましいです。 reselectを用いると、メモ化されるため値の更新が無い限りオブジェクトでもインスタンスは変更されず、先程のshallowEqualがなくても再レンダリングが抑制されます。極端な話、reselectを全てに用いて、大きなオブジェクトで切り出さなければあまりパフォーマンスについて意識する必要はありません。しかし、reselectでメモ化する際には少なからずメモ化のための処理が負荷されます。
あまり大きな負荷ではないので、神経質になる必要はないとは思いますが、個人的にはただ値を返すuseSelectoruseSelector(state => state.hoge)といった普通の書き方で行い、切り出した値に何かしらの処理を加える場合は、reselectを用いるのが適切かなと思います。

まとめ

Reduxは詰まるところ大きなreduce関数であるということが改めて勉強することでやっと腹落ちできました。初期値として与えられたinitial stateにアクションで定義された処理を通して、また新たなStateを獲得する。結局はこれの繰り返しであり、reduce関数が自由度の高い関数であると同様に、Reduxも非常に自由度が高く、全然アンチパターンであっても実装はできます。ただし、アンチパターンだとパフォーマンスに劣り、メンテナンス性も低く、良いアプリケーションにならないため、常に良い設計を意識する必要があると認識できた次第です。

ここでは私がReduxを改めて勉強して気付いたこと、導き出したことをTipsとしてまとめました。本稿は現時点で自分の考える最良のアプローチですが、Reduxに強い人から見るとまだまだブラッシュアップすべき点が数多くあると思います。もし改善すべき点や誤っている点などありましたら、本記事からのツイートやはてブでお知らせいただけると幸いです。 個人として今後ももっともっとフロントエンドについて学び、本テックブログを通して意見を交わしていければと思っておりますので、よろしくお願い致します!

0 コメント:

コメントを投稿