Redux/Storeのデータを正規化しパフォーマンスの向上を狙う

f:id:golazooo23:20210624163200p:plain

はじめに

こんにちは、スタメンでエンジニアをしている手嶋です。普段は、React+TypeScriptでフロントエンドメインで開発をしています。 弊社のプロジェクトではフロントエンドの状態管理ライブラリとしてReduxを使用していますが、直近のプロジェクトにおいてReduxStoreに格納するデータを正規化することで多くの恩恵を得られた為、今回はそのメリット及び具体的な正規化の方法について紹介したいと思います。 ※Redux公式ドキュメントでもStoreの正規化が推奨されています。

正規化の概要及びメリットについて

正規化の概要

正規化されたデータがどのようなデータなのか示すために以下に例を挙げます。 今回はユーザーによるプロフィール入力があるアプリケーションにおけるAPIを例に挙げます。(ユーザーが各質問に対して回答を行うようなデータです) 実際のプロジェクトにおける正規化の対象としては、APIから取得したデータ全般を想定していただければと思います。

正規化前のデータ(APIレスポンス)

const profileData = [
  {
    id: 'questionId1',
    name: '勤務先',
    answers: [
      {
        id: 'answerId1',
        content: '東京都渋谷区',
        private: false,  //公開非公開の設定
      },
    ],
  },
  {
    id: 'questionId2',
    name: '性別',
    answers: [
      {
        id: 'answerId2',
        content: '男性',
        private: false,
      },
    ],
  },
  {
    id: 'questionId3',
    name: '学歴',
    answers: [
      {
        id: 'answerId3',
        content: 'hoge高校',
        private: true,
      },
      {
        id: 'answerId4',
        content: 'fuga大学',
        private: true,
      },
      // 同じような学歴のデータが続く
    ],
  },
  // 同じような質問と回答のデータが続く
]

上記のままでもStoreに格納することはできますが、以下のデメリットが発生します。

  • 各エンティティ(上記でいうquestionsとanswers)のデータが混在してStoreに保存されているため、どこかを更新する際に、その対象が適切に更新されているか不明瞭である
  • データがネスト化されていることで、データの更新ロジックが複雑になったり、処理に想定以上の時間を要する可能性がある(特にデータが多い場合)
  • イミュータブルなデータの更新はそのデータの全ての親要素の更新も伴う為、不必要なデータの更新が起こることになり、結果的にコンポーネントで不要な再描画が発生する可能性が高い(上記の例でいうと、ある単一のanswerに対する更新であってもprofileData全体への更新処理となる為、profileDataを参照している全てのコンポーネントで再描画が発生してしまいます)

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

正規化後のデータ

// questionのテーブル
const questions = {
  ids: ['questionId1', 'questionId2', 'questionId3'],
  questionId1: {
    id: 'questionId1',
    name: '勤務先',
    answerIds: ['answerId1']
  },
  questionId2: {
    id: 'questionId2',
    name: '性別',
    answerIds: ['answerId2']
  },
  questionId3: {
    id: 'questionId3',
    name: '学歴',
    answerIds: ['answerId3', 'answerId4']
  },
}

// answerのテーブル
const answers = {
  answerId1: {
    id: 'answerId1',
    content: '東京都渋谷区',
    private: false
  },
  answerId2: {
    id: 'answerId2',
    content: '男性',
    private: false
  },
  answerId3: {
    id: 'answerId3',
    content: 'hoge高校',
    private: true
  },
  answerId4: {
    id: 'answerId4',
    content: 'fuga高校',
    private: true
  },
}

Redux/Storeが正規化されている状態を定義すると以下となります。

  • データの重複がない
  • データ(エンティティ)ごとに「テーブル」をstateとして持っている
  • 正規化されたデータはIDをkeyとして、データ自体がそのvalueとなる
  • IDを持つ配列は順序を示す

正規化のメリット

メリットとしては以下が挙げられます。

  • Storeの整合性が常に取れている
  • ネストが浅いので複雑性がない。また、値の更新に伴う不必要な値の巻き込み更新が最小限になる
  • 値の更新時にjsの配列操作であるfilterなどを用いなくてもダイレクトに参照可能である(例えばanswerId3を更新したい場合state.answers[answerId3]のようにシンプルに参照することができます。これはデータ探索の観点でパフォーマンスの向上が期待できます。)
  • 前述のように本来操作が必要の無いデータに対する操作(更新/削除などの処理)が無くなる為、コンポーネント再描画などの観点からパフォーマンス面においても恩恵を得られる

正規化の方法について

次に正規化の具体的な手法を紹介します。 弊社のプロジェクトでは基本的にnormalizrというライブラリを使っています。導入方法や詳細は公式のgithubが参考になると思います。 APIからデータ取得後、Storeにデータを格納する直前に正規化の処理を挟んでいます。以下は上述のprofileDataを正規化する場合の例となります。

normalizrを使った正規化の例

処理としては大きく2段階になっており、エンティティ毎のschemaを定義する処理と、実際にAPIのレスポンスをnormalizeして呼び出し元に値を返す処理です。 あくまで一例ですので、同じようschema定義とnormalizeをする事であらゆるデータを正規化する事ができると思います。

import { normalize, NormalizedSchema, schema } from 'normalizr'
import { ProfileDataType } from 'types/profile'

// エンティティ毎のschemaを定義する関数
export const createProfileDataSchema = () => {
  const profileData = new schema.Entity('questions', {
    profileAnswers: [new schema.Entity('answers')],
  })
  return [profileData]
}

// Reducerから呼び出す関数。APIから取得したデータを引数として受け取り、正規化後の結果を返す。
export const profileDataNormalizer = (
  profileData: ProfileDataType[]
) => {
  const profileDataSchema = createProfileDataSchema()

  return normalize(profileData, profileDataSchema)
}

また、弊社のプロジェクトでAPIのデータが木構造になっている複雑なケースもありましたが、上記の処理を少し変更するだけ同じように正規化する事ができました。以下に続けて紹介します。

APIレスポンスが木構造の場合

APIのレスポンスでquestionが木構造になっており、ある質問の子要素として別の質問があるパターンです。

const profileData = [
  {
    id: 'questionId1',
    name: '勤務先',
    answers: [
      {
        id: 'answerId1',
        content: '東京都渋谷区',
        private: false,
      },
    ],
  },
  {
    id: 'questionId2',
    name: '基本情報',
    // ここに木構造のデータとしてchildrenのデータが含まれる
    children: [
      {
        id: 'questionId5',
        parentQuestionId: 'questionId2',
        name: '性別',
        answers: [
          {
            id: 'answerId5',
            content: '男性',
            private: false,
          },
        ],
      },
      {
        id: 'questionId6',
        parentQuestionId: 'questionId2',
        name: '誕生日',
        answers: [
          {
            id: 'answerId6',
            content: '1月1日',
            private: false,
          },
        ],
      },
    ],
  },
  {
    id: 'questionId3',
    name: '学歴',
    answers: [
      {
        id: 'answerId3',
        content: 'hoge高校',
        private: true,
      },
      {
        id: 'answerId4',
        content: 'fuga大学',
        private: true,
      },
      // 同じような学歴のデータが続く
    ],
  },
  // 同じような質問と回答のデータが続く
]

正規化の処理

この場合は、正規化の処理の中でchildrenを定義する処理を追加することで、同じように正規化することができます。

// normalizrを使った正規化の処理
import { normalize, NormalizedSchema, schema } from 'normalizr'
import { ProfileDataType } from 'types/profile'

// エンティティ毎のschemaを定義する関数
export const createProfileDataSchema = () => {
  const profileData = new schema.Entity('questions', {
    profileAnswers: [new schema.Entity('answers')],
  })
  // 木構造のデータを扱う場合に追加した処理
  profileData.define({ children: [profileData] })
  return [profileData]
}

// Reducerから呼び出す関数。APIから取得したデータを引数として受け取り、正規化後の結果を返す。
export const profileDataNormalizer = (
  profileData: ProfileDataType[]
) => {
  const profileDataSchema = createProfileDataSchema()

  return normalize(profileData, profileDataSchema)
}

APIの仕様次第で独自で実装した方が費用対効果が大きいケースもあると思いますが、ある程度複雑なAPIであってもnormalizrでスムーズに正規化することが可能ですので、正規化の一つの選択肢として十分候補になると考えています。

おわりに

Redux/Storeに格納するデータを正規化するメリットや手法について紹介しました。 正規化するコスト以上に得られるメリット(Storeの複雑性の排除・コンポーネント再描画対策)が大きいと感じたので、今後のプロジェクトにも導入していきたいと思っています。 最後まで読んで頂きありがとうございました。

スタメンでは一緒にプロダクト開発を進めてくれる仲間を募集しています! 興味を持っていただいた方は、是非下記の募集ページを御覧ください。

参考にさせていただいた資料