TwitterやSlackのRedux Storeを覗く
金井 優希
この記事はリクルートライフスタイル Advent Calendar 2019の20日目の記事です。
HOT PEPPER Beauty cosmeの開発を担当している金井です。
今年は社内システムなどのためにReduxを使ったアプリケーションをいくつか書いたのですが、Storeの設計変更は影響範囲が大きく難しいため、Storeの設計は重要だと感じました。他のサービスではどのように設計しているのか気になり、有名なWebサービスの中からReduxを使っているサービスを探してStoreの中を調べました。この記事では、今回調べたサービスのそれぞれのRedux Storeの特徴や設計について紹介します。
ただし、個人による自由研究であり、各サービスの公式の見解ではないことはご注意ください。
Redux公式が紹介している設計方針
各サービスのRedux Storeを覗く前に、Redux公式が紹介している設計方針について確認します。
Reduxの公式WebページにはRecipesというページがあり、設計の方針のプラクティスがまとまっています。
基本構造
このRecipesには以下のような3つのdataを作るのが基本だと書いてありました。
- Domain data: アプリケーションが表示したり変更したりするデータ
- e.g. TODOアプリなら、todo/doing/done
- App state: アプリケーション独自の振る舞いのためのデータ
- e.g. データの選択状態やデータフェッチのローディング状態
- UI state: 現在の表示方法のためのデータ
- e.g. モーダルが開かれているかどうか
なのでStoreは以下のようなオブジェクトになります。
1
2
3
4
5
6
7
8
9
10
{
domainData1 : {},
domainData2 : {},
appState1 : {},
appState2 : {},
ui : {
uiState1 : {},
uiState2 : {},
}
}
ref: Basic Reducer Structure and State Shape · Redux
正規化
Recipesでは正規化についても言及しており、リレーショナルデータを管理する場合はデータベースのように正規化することを推奨していました。
以下は投稿が複数のコメントを持つ例です。postsはcommentsのidだけを持っています。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
{
posts : {
byId : {
"post1" : {
id : "post1",
author : "user1",
body : "......",
comments : ["comment1", "comment2"]
},
"post2" : {
id : "post2",
author : "user2",
body : "......",
comments : ["comment3", "comment4", "comment5"]
}
},
allIds : ["post1", "post2"]
},
comments : {
byId : {
"comment1" : {
id : "comment1",
author : "user2",
comment : ".....",
},
"comment2" : {
id : "comment2",
author : "user3",
comment: "....."
}
},
allIds: ["comment1", "comment2"]
}
}
ref: Normalizing State Shape · Redux
残った疑問点
Recipesはさまざまな疑問に答えてくれますが、それでもまだ基本構造の分類方法や分割粒度など、わからないことがありました。
- Domain data
- REST APIのレスポンスのJSONをそのまま保持する?
- 正規化はどうやってやる?
- アプリのモデルオブジェクトに変換する?
- App state & UI state
- ページごとに作る?パーツごとに作る?
そこでいくつかの有名なサービスの中からReduxを使っているものを探してそのStoreの構造を調べてみようと思いました。
Redux Storeの調べ方
調べる対象のアプリケーションがどのReactのバージョンで実装されているかによって調べ方が異なります。方法1でStoreが見れなかった場合、方法2を試してください。
方法1
- Chrome DevToolsでComponentsタブを開く
connect
しているコンポーネントを選択する- このとき、右側にlegacy contextと書いてない場合、方法2を試してください。
- consoleタブを開くと
$r
に選択したコンポーネントが束縛されているので$r.store.getState()
するとstateが見られる
方法2
- Chrome DevToolsでComponentsタブを開く
connect
しているコンポーネントを選択するLog this component data to the console
をクリックする- consoleタブを開くと
[Click to expand] <Connect(t) />
と表示されているのでクリックする - Hooksを開いて
name: "Context"
なオブジェクトを選びvalueを開く store
の上で右クリックしてStore as global variable
を選択するtemp1
という変数にStoreが束縛されているのでtemp1.getState()
するとstateが見られる
ここからは各サービスのRedux Storeを調べた結果を紹介します。今回は3つのサービスを調べました。
- Slack
- Airbnb
それぞれのStoreで特徴的だった構造、全体的な構造、その他の特徴についてまとめました。
SlackのStore
まずSlackを調べます。Domain dataの構造において発見がありました。
Domain dataに中間テーブルのような構造がある
チャンネルに所属するメンバーを表すDomain dataが図のような構造になっていました。
チャンネルのプロパティとしてメンバーのIDの配列を持っても良さそうなところですが、membershipOrderedというオブジェクトにチャンネルごとのメンバーのIDの配列を持たせて中間テーブルのように使うことでチャンネルとメンバーとの関連を表現しています。データモデルとしても、チャンネルとメンバーの関係は中間テーブルを使うのが一般的な設計です。
Recipesの通り、リレーショナルモデルを管理する場合はDomain dataをデータベースと同じように取り扱うというのを実践しているのかなと思いました。
また、今回の例では、RDBのようにDomain dataを構造化したことで、再レンダリングに不要な変更をComponentがlistenしなくなっています。Slackの特性上、チャンネルのdataを扱うコンポーネントは多いと考えられるため、メンバーのjoin/leftの度に再レンダリングされるのはコストが高くなります。このような場合はshouldComponentUpdate
を実装して再レンダリングを防げますが、再レンダリングの対象にならないようDomain dataを構造化するという方法もあると分かりました。
全体的な構造
stateは以下のように全てのデータがrootにフラットに置かれていました。
その他
- 全チャンネルと全ユーザーの情報を最初にロードする
- メッセージは遅延ロード
TwitterのStore
続いてTwitterを調べます。Twitterというサービスの特性をうまく反映したDomain dataとApp stateだと思いました。
Domain dataそれぞれがデータフェッチのステータスを持っている
Recipesによると、データフェッチのステータスはApp stateであるとされていましたが、Twitterは各Domain dataもデータフェッチのステータスを持っていました。Domain dataを正規化しているので、データフェッチの状態はApp stateが持つよりもDomain dataが持つほうが整合性を保ちやすそうです。
正規化にNormalizrを使っていそう
2017年の記事ですが、Twitterの公式ブログでモバイル版のTwitterの技術的な紹介をしています。この中でNormalizrというライブラリを使っているという記述がありました。
ref: How we built Twitter Lite
ref: GitHub - paularmstrong/normalizr: Normalizes nested JSON according to a schema
Normalizrを使う場合はRedux storeに entities
というキー以下に正規化したデータをいれるという慣習があります。現行のTwitterがNormalizrを使っているという確信はありませんが、entities
以下に正規化されたデータが入っており、Normalizrにより正規化した場合に構築される構造になっているため、いまもNormalizrを使っていると推測します。
全体的な構造
Domain dataは entities
キーの配下にまとめられています。他の2種類のデータはrootに置かれていました。
その他
- タイムラインはDomain dataではなくApp state
- タイムラインはフォローやブロックをすることで、同じ時間帯のタイムラインでも過去に表示されたものと同じものとはならないという特性がある
- セッションごとに生成されるその時々のデータになるのでApp stateとして設計されているのかなと思いました
- Domain dataはキャメルケースになっていない
- JavaScriptの命名規則にのっとるとキャメルケースにするべきだが、おそらくNormalizrでAPIのレスポンスをそのままStoreに入れているためスネークケースになっている
- denormalizeをしたオブジェクトもスネークケースになるが、そのままComponentに渡していた
AirbnbのStore
最後にAirbnbを調べます。これまで見てきたサービスはそのサービス特有の特性に特化したアプリケーションでしたが、Airbnbは特集ページがあったり宿の一覧ページがあったり予約をするフォームがあったりと、一般的なWebサービスでありがちな機能が実装されています。
ページごとにstateを分けている
ページごとにstateを分けていました。
- experiencePdp
- 体験を重視する宿用のページ
- luxuryPdp
- 高級だったりおしゃれだったりを重視する宿用のページ
- userProfile
- ユーザーのプロフィールを編集するページ
- …
それぞれのページごとに設計も異なりました。例えばluxuryPdpは entities
の下にDomain dataが格納されていますが、userProfileは api
のしたにDomain dataがあります。各Domain dataの正規化のされかたも異なるようでした。
おそらく、各ページで開発しているチームが異なりそうです。いわゆるマイクロフロントエンド形式です。数百人のエンジニアで同じサービスを開発しているので、ページごとに独立してstateが影響を及ぼし合わないよう工夫しているのだと思いました。
全体的な構造
ページごとに分割されています。stateはそれぞれのページのやり方で配置されています。
その他
- headerとかfooterの単位でもstateが分けられている
- 共通して使うパーツはstateも共通化していそうです
まとめ
各サービスのRedux Storeを眺めました。Recipesに紹介されているプラクティスと比較していくことで、それぞれのアプリケーションの特徴を見られました。
最初に挙げた疑問点は以下のように解消されました。
Domain data
- REST APIのレスポンスのJSONをそのまま保持する?
- => 正規化する
- 正規化はどうやってやる?
- => Normalizrを使う
- アプリのモデルオブジェクトに変換する?
- => 独自モデルは作らずNormalizrでdenormalizeしたオブジェクトを使う
App state & UI state
- ページごとに作る?パーツごとに作る?
- => Airbnbのようなページごとで大きく特性が変わる場合はページごとに分割する
- => SlackやTwitterのようなアプリではパーツごとに作ることが多い
React/ReduxといったらFacebookなのですが、Chrome DevToolsで見てみたらFacebookはReduxを使っていないことがわかりました。同じくFacebookが作っているInstagramも調べたところ、おおむねRecipesの通りの設計になっており、紹介することが少なかったので省略しました。しかし、リファレンス実装として一番参考になるかもしれません。
また、この記事は以下の記事にインスパイアされて執筆しました。
Dissecting Twitter’s Redux Store - Statuscode - Medium
Reactを使っているアプリケーションの場合Chrome DevToolsのアイコンが光るので、最近はついついどうやって作っているのかなと眺めてしまいます。
良いお年を!