みなさんこんにちは! VRoid Hubでフロントエンドエンジニアをしている花倉ミツカ (a.k.a. ラグ)です 🙌
今回のpixiv insideはちょっとだけお仕事から離れて(ガチ)アイスブレイクです。私が1年ほど開発しているFluxフレームワーク、Fleur (フルール, @fleur/fleur)
について、その設計や使い方についてご紹介させていただきます!
目次
- どういうフレームワーク?
- 実際の使い方
- 質問
- まとめ
どういうフレームワーク?
pixiv Sketchで採用されている Fluxible というFluxフレームワークを参考に、「書きやすさ」と「現代的な機能の採用」の二点を重視してTypeScriptでフルスクラッチしました。(Fluxibleは私が知ってる中で一番"整っている"フレームワークだと思っています♨)
Fleurの大規模なプロダクションでの採用実績はまだありませんが、Web製映像編集ソフト Delir(デリール)というパフォーマンスセンシティブなプロダクト内で1年間、APIとパフォーマンスの調整を行って開発されています。
特徴
- Server-side rendering(以下、SSR) ready
- express + FleurのSSR検証テストやTodoMVCのSSR実装などの統合テストをやっている
- React Hooks対応(
useFleurContext()
,useStore()
など、後述) - コードの書き心地が良い
- 標準で非同期処理に対応
- immer.js 組み込みのStore
- SSR対応ルーター
- API fetchを考慮した設計
- Dynamic importによる遅延コンポーネント読み込みに対応(コード変換不要)
- redux + react-redux に迫る性能
- 一部のベンチマークではreact-reduxより早い場合もある
- Redux DevTools対応
- Skip actionには非対応
どういう経緯で生まれたか
Fleurは、FluxUtilsで実装されていたDelirのより堅牢な移行先を模索する中で生まれました。
設計面からFluxibleが移行先として一番良い(※)と考えていましたが、Fluxibleの型定義は存在せず、自力で型定義を行うにも型推論と相性の悪いAPIだったため、「じゃあ一発自分で作るか〜」という気持ちを固めました。 (実際にpixivの「グッズ販売機能」開発時にFluxibleの型定義を書きましたが、めちゃ苦しかったです🤢)
その後、ピクシブの各プロダクトにおけるフロントエンド・BFFの実装や、Delirでの使用感、React Hooksを参考に、どういうAPIならよりシンプルかつ堅牢で、プロダクションでも使えるコードを書けるかという方向で設計を研ぎ澄ませていきました。
特にFleurの生まれ元のプロダクトのDelirでは、JavaScriptのメインスレッド上に「映像のレンダリング処理」と「UI処理」が同居しているため、パフォーマンス方面で問題がないようにチューニングしています。
※余談
Delirの構成に「Redux + react-redux + redux-thunk」を使うことも検討しましたが、当時はまだimmer.jsを認知しておらず、かつDelirのエンジン側APIの独立性を担保する必要があったため、エンジンのAPIを変更せずに採用できるフレームワークとしてFluxibleが一番良さそうという事になっていました。(プロジェクトスキーマがネストしたクラス構造に依存していて、イミュータブルなオブジェクト郡に変更するのが困難だった)
最近になってimmer.jsが変更されたオブジェクトのプロトタイプを保持するようになったようなので、今なら違うやり口もあるかもしれません。(でもフロントエンドでJSON以上のオブジェクトをStateに使うのはやめましょう。大体Viewがリンボになります🔥)
より書きやすくて堅牢なFluxフレームワークを求めて
まずはFleurでのAction / Operations / Storeの定義を見ていきましょう。
Action自体の定義とその型定義が一体化しているので、記述が散らばらず、一つのActionの定義のために複数箇所のコードを書かないといけない、という工程が減らされています。このActionの定義を、Fleur内部ではAction Identifier
と呼んでいます。
Operationsは、最初から非同期処理に対応しています。そのため、thunkかsagaかという選択が必要ありません。
Storeは、listen関数
と.updateWith()
が特徴です。listen関数
はAction Identifierを元にそのActionのハンドラの引数型を推論してくれます。Fluxibleではhandlers
というプロパティでAction typeとそのハンドラメソッド名を指定する形でしたが、よりシンプルで型推論に優しいコードを考えた結果このようなAPIになっています。(mizchi/hard-reducerを参考に reducerStore()
という関数でも作れるようになっています。)
実利用シーンを元に設計されたルーターも欲しかった
Fleurの標準のルーター @fleur/route-store-dom
は pixiv Sketch や VRoid Hub など、SSRを行っているサービスのプロダクションコードを元に設計しています。
私が調査した範囲だと、現在Reactの周辺に存在するルーターライブラリは、概ね共通して「APIリクエストについてのベストプラクティスが存在していない」という問題を抱えているように見受けられました。react-router
を利用しているVRoid Hubでもそのあたりはかなり汚い自前実装を行っています。その点については、Fluxibleのfluxible-router
はかなり理想的な解に近いAPIを持っていたので、Fleurもそれを踏襲しました。
同期処理が前提となっているReactの世界に、非同期になりえる処理を持つというのが現状まだ相性が悪く、React Suspenseが入ってもSSR的にはしばらく辛い状態が続くんじゃないかなと思っています😔 (Googlebotのエンジン更新によりSSR要らなくなるのでは?という説もありますが)
fluxible-router
にはなかった機能として、コンポーネントのdynamic importの対応がFleurでは実装されています。既存のルーターライブラリではreact-loadable
で頑張ったり、babelによるtransformなどを用いてSSRとCSRのコードを分けるなど大掛かりなやり方が必要でしたが、FleurではルーティングがReactライフサイクルの外で行われることで、transformなしでもdynamic importへの対応が出来ます。
実際の使い方
Fleurでは、アプリケーションを作成するためにActions, Stores, Operations, Viewの最低4要素が必要です。 ちょっと多いなぁという気がしますが、中大規模アプリを書くなら、割とペイできるんじゃないかと考えてます。(reduxのAction CreatorがActionsとOperationsに分離された感じです)
おすすめのディレクトリ構造は以下のような感じです。
app/
└ components/
└ domains/
└ Entity
└ actions.ts
└ operations.ts
└ store.ts
ここからは順番にサンプルコードを添えて流れを説明していきます。
Actions
FleurでのActionの実態はただの型ストアで、何もしない関数です。action()
関数はpayloadの型を引数に受け付け、実際には呼べない関数を返します。
Fleur内ではこの時生成される関数インスタンスをAction Identifier
と呼んでおり、dispatch時に関数インスタンスとの厳密等価性を見てStoreのリスナを発火します。(JavaScriptトリックです)
Store
Storeではlisten()
関数を使い、どのActionに対してハンドリングするかを指定します。
Storeの特徴は2つあります。
- 先に述べた
Action Identifier
により、payload
の型が推論されます。 this.updateWith()
。 このメソッドの実態はimmer.jsのラッパーなので、draftに対してミューテーションを起こしても、stateの更新はイミュータブルになります。 返り値はFleur側で無視するので、draft => { ... }
のように波括弧を付ける必要はありません。
.updateWith()
は状態を更新して、View側へ変更を通知しますが、この更新通知はrequestAnimationFrame
を用いてバッファリングされる(※1, 2)ため、単一のハンドラ内で複数回.updateWith()
をコールしても、UIへのブロックは最小限になります。
listen関数がどのようにActionとハンドラを紐づけているかについては、以下のコードをご参照ください。
listen()
の中身: fleur/src/Store.ts#L12- Actionとの紐づけ: fleur/src/AppContext.ts#L116
※1 一回のdispatchで複数のStoreの状態が変化する場合でも、それらを取りまとめてバッファリングされます。
Store#updateWith
の実装: fleur/src/Store.ts#L47- 変更通知のバッファリング: fleur/src/StoreContext.ts#L12
※2 SSR時はバッファリングを行わず、同期的に処理します。
Operations
これはFleur独自の名称ですが、APIとの通信や副作用を起こす層です。redux-thunkなどに相当します。 エンティティの正規化も、正規化処理を関数に切り出したりnormalizrを使ったりしてこの層でやると取り回しが楽です。
各メソッドのcontext
には{dispatch, getStore}
が渡ってきており、operation内からStoreの状態を参照することも可能です。例えば「Storeにあるユーザーの認証情報がAPIリクエスト時に必要」という場合に便利です。operations()
関数自体は型推論のためだけのもので、特に何もしていません。
OperationsはPromiseに標準対応しているので、軽率にasync/awaitをお使いいただけます。
View
最後にこれらをViewへ繋ぎます。
ViewではuseStore
と useFleurContext
を利用します。
useStore
には第1引数に監視対象のStoreクラスを、第2引数にStoreから状態を取り出す関数を渡すことで、Storeの更新があった時にコンポーネントの更新が走ります。(ここらへんはreact-reduxのconnect
と同じようなものですね)
useFleurContext
は{ getStore, executeOperation }
を返すHooksです。
ユーザーのアクションによって何かしらのoperationを実行する際にはexecuteOperation
に実行したいoperationとその引数を渡します。 getStore
は「見た目には影響がないけど、コールバック内でStoreの状態が欲しい」というシーンで有効です。(基本的にOperations内で済むはずです)
最後に、アプリケーションの立ち上げ部分を書けば一通りのフローは完成です!
質問
Routerは?
Routerのサンプルは@fleur/route-store-domのREADMEをご覧ください。
SSRのサンプルは?
この記事中では割愛しますが、こちらのサンプルをご覧ください。
大規模な環境で動作させた時にメモリリークが発生しないか?という点のテストが不足しているので、フィードバックを頂けると幸いです。
パフォーマンス、数値的にはどうなの?
Travis CI上で Fleur vs Fluxible vs react-redux のベンチマークを取った感じこのようなスコアになっています。
パフォーマンスを最適化した結果、Fluxibleより圧倒的に早く、react-reduxに対して肉薄するか追い越すほどのパフォーマンスになりました。
Fleurが内部で行っている最適化は主に以下の3つです。
- Store内での短時間でのupdateWith()コールのバッチ通知
- Storeをまたいだ短時間で複数のdispatchのバッチ通知
- リッスン中の複数のStoreからの変更通知のコンポーネント側でのバウンス
とにかく、「Reactで再レンダリングを発生させると重い!!!あとは塵!!!」という感じで最適化させました 💨
まとめ
メジャーバージョンリリースをしたばかりで、React SuspenseやGraphQLまわりの知見が少ないので改善点はまだあると思いますが、大体のチョットフクザツな案件には対応できる出来になっていると思います。
「なんで21世紀も令和になったのに、俺たちは20世紀にはもう書かなくてよかったルーティング処理を書いてるんだ?」という気持ちになった方はぜひ一回使ってみてください、きっと気持ちよくなれますよ 🔫☺
最後に、ピクシブ株式会社ではアイスブレイクと称してドロップキックな話をしがちな強いフロントエンドエンジニアをいつでも募集しています。