Fluxフレームワーク “Fleur” 完全攻略ガイド
2019年末に投稿した https://qiita.com/hanakla/items/3b95dffad062fc56ffb3 から記事を引き上げてきました!
Q&Aあたりの一部の情報を2021年1月3日時点の情報に更新してあります!
今の所、当時より大幅に変わったAPIなどはありませんが、pixiv コミックの運用で見つかったバグなどがあらかた修正され、プロダクションでも安定して利用できるようになっています!
こんにちは〜 ピクシブのフロントエンドエンジニアでFleur開発者のHanakla(Twitter: @hanak1a_) です!
React #2 Advent Calendar 2019 24日目となるこの記事では、モダンで小中規模くらいのフロントエンドのためのReact用Fluxライブラリ「@fleur/fleur
」について作者としてダイマさせて頂きます!
Fleurについては、今年5月にpixiv insideにてご紹介させていただきましたが、この時よりさらに改善されていますので、その点も含めて解説していきたいと思います!
(@ky7ieeeさんのTypescriptとReact HooksでReduxはもうしんどくないという記事が出ているところ恐縮なんですが、typescript-fsa
… お前3年前に居てほしかったよ……!)
リポジトリ: https://github.com/fleur-js/fleur
話すこと
- VRoid Hubの実際の構成を基にしたFleurでのアプリケーション設計
- Redux設計パターン・Redux Style Guideとのかみ合わせ
(この規則はいいぞ、この規則は現実的じゃないぞなど)
こういう時にFleurを使ってくれ
-
とりあえず何も考えず最速で堅牢なそこそこスケールするSPAを組みたい!
- 「君が欲しい物 - Best Practice Remix -」がFleurにはあります。
- redux-thunk or redux-saga、reselect、typescript-fsa、Fleurには全部ある!
- Next.jsでも使える!!!
-
Bless of Type(型の祝福)を気持ちよく受けながらSPAを作りたい
- コードの書き心地はReduxに頑張って型をつけるよりめちゃくちゃにいいです。これは自信を持って言えます。FleurはTypeScriptの型推論を受けるための本当に最小限のAPIを用意しています。
-
ちゃんとテストしたい!
- FleurはOperations / Store / Componentのそれぞれに対してテストのし易いAPIを提供しています。
- 後述しますが、外部通信処理のDIも非常にシンプルに実装されています。
- ここらへんは
@fleur/testing
というライブラリにまとまっています。
- FleurはOperations / Store / Componentのそれぞれに対してテストのし易いAPIを提供しています。
-
やんごとなき事情でStoreにJSONじゃないオブジェクトとか副作用がど〜〜〜しても必要!
- 基本的にはJSONを使えというのはReduxと同じ方針だけど、リアルワールドにおいてはThree.jsのインスタンスや、レンダリングエンジンのインスタンスを状態管理に載せなくちゃならない場面があるんだ
世の中のSPAの8割くらいのケースを満たせる割と薄いFluxライブラリがここにある。少なくとも動画編集ソフトくらいまでなら動かせている。
さあ、いますぐyarn add @fleur/fleur @fleur/react
。
Fleur - A fully-typed, type inference and testing friendly Flux framework
Fleurは2019年的なAPIで、書きやすく、より型に優しく、テスタブルに構成されたライブラリです。
Fluxible
やRedux
をベースにしていますが、基本的な設計に対して迷いや再実装が生じづらいようにAPIを設計しています。
TypeScriptがある時代前提で設計されているので、Reduxで型にお祈りを捧げるときにありがちなexport enum ほへActionType
とかexport type なんとかActionTypes = ReturnType<typeof ほげ>
みたいなのを頑張って書く必要がありません。これが標準で提供されているの圧倒的にアドです。Redux初心者がtypescript-fsa
にたどり着くにはあまりにも超えなければいけない障壁が多すぎます。
またNext.jsとの併用にも対応しており、npx create-fleur-next-app
コマンドでNext.js + FleurによるSPA開発も可能です。(Next.jsなしのSSRでもご利用いただけます、ルーター用意されてます。)
Redux devtoolsにもとりあえず対応しているため、デバッグツールにも困らないと思います。
使い方・設計編
それではFleurの使い方、基本的な設計パターンを見ていきましょう。ここでは「VRoid Hub」をFluerで実装するというケースでコードを例示していきます。
Fleurは大まかに↓の4つの要素を必要としています。
オススメのディレクトリ構成
Fleurでは、Re-ducks パターン風のディレクトリ構成を推奨しています。Re-ducksパターンは弊社の色々なプロダクトでも割とよく採用されている構成です。
Fleurが推奨する構成は具体的には以下のようになります。
- app
- spec -- ここにテスト用のモック関数とかを詰める
- mocks.ts
- components
- domains
- Character
- operations.ts
- actions.ts
- selectors.ts
- store.ts
- model.ts -- フロント側でどうしても必要なビジネスロジックは関数化してここに置く
- index.ts -- Re-ducksパターンではあるけど任意。暇なら置いてよい
- operations.test.ts
- selectors.test.ts
- store.test.ts
- model.test.ts
- User
- operations.ts
- actions.ts
- selectors.ts
- store.ts
- model.ts
Re-ducksパターンでドメインを分けていくと、selectorやmodelがそのドメインに関係していないといけないことを強要出来るので、「汎用的なutils.ts
にドメインロジックをアーーーーー💕💕💕💕」みたいな事態を防げます。
もっともこの構成は、あくまで中規模化したプロダクトに対しての推奨で、より小さなプロダクトやプロトタイピングには手間が多いと思います。最小構成で行くならdomains/{Character,User}.ts
にActionとかOperationsとかをドメイン毎に全部詰めるみたいな構成でもいいでしょう。exportがごちゃごちゃしてなければ最低限の治安は保てるはずです。
大規模アプリでどういう構成にしたらいいのかというのは今調べています。みなさんのプロダクトのディレクトリ構成とかフロントエンドアーキテクチャを語る記事を募集しています。
それでは各ファイルの中身を見ていきましょう
Operations
まずはOperation(アプリにおける手続き)を定義します。とりあえずキャラクター情報を取得してみましょうか。キャラクターにはキャラクターの情報(Character entity)とその投稿ユーザーの情報(User entity)があるものとします。
// domains/Character/operations
import { operations } from '@fleur/fleur'
import { CharacterActions } from 'domains/Character/actions'
import { UserActions } from 'domains/User/actions'
import { normalize } from 'domains/normalize'
import { AppSelectors } from 'domains/App/selectors'
import { API } from 'domains/api'
export const CharacterOps = operations({
// 特定のキャラクターの情報を取得する
async fetchCharacter(context, characterId: string) {
context.dispatch(CharacterActions.fetching.started, { characterId })
// 認証情報取る
const credential = AppSelectors.getCredential(context.getStore)
try {
// APIからデータを取る
const response = await context.depend(API.getCharacter)(credential, characterId)
// Entityを正規化したりDateに変換したりは`normalize`でやったことにする
const { user, character } = normalize(response)
// 正規化したデータをStoreに送りつける
context.dispatch(CharacterActions.charactersFetched, [ character ])
context.dispatch(UserActions.usersFetched, [ user ])
context.dispatch(CharacterActions.fetching.done, { characterId })
} catch (error) {
rethrowIfNotResponseError(error)
context.dispatch(CharacterActions.fetching.failed, { characterId, error })
}
},
// 他のoperationの定義が続く
})
Operationで使うAPIと設計のコツを見てみましょう
-
context.getStore
- Storeのインスタンスを取れます。
context.getStore(CharacterStore)
のような感じで使いますが、selectorに任せてしまうので直接コールする機会はあんまりないかもしれません。 -
context.depend
- 渡されたオブジェクトをそのまま返します。「は?」って感じですね。
これはテスト時にDependency Injectionを行うための仕組みです。後述します。 - normalize - エンティティの正規化はOperationでやりましょう。純粋関数として切り出しておくとテストもしやすくて良いです。少なくともStoreで正規化するのはDRYじゃないのであまりおすすめしません…
-
context.dispatch
- Actionを発行します。
normalizeの正規化単位
APIから振ってきたJSONは基本的にはEntity単位で切っていきます。
例えばVRoid HubのCharacter Entityは以下のような構造でAPIから降ってきます。
interface SerializedCharacter {
character_id: string
name: string
create_at: string
/** 投稿者 */
user: {
user_id: string
name: string
icon: SerializedUserIcon
}
}
このJSONをDB的に分割するとCharacter
とUser
とUserIcon
になります。
しかし、UserとUserIconは基本的にセットで使われているので、特に分割する必要がありません。なので分割せず、以下のような2つのEntityに正規化しています。
interface Character {
character_id: string
name: string
created_at: Date
user_id: string
}
interface User {
user_id: string
name: string
icon: SerializedUserIcon
}
Actions
次にActionsの定義です。Fleurにおいてこれはただの識別子と型宣言であり、Reduxと違ってこのActions自体はコールすることは出来ません。アプリケーションでどういうイベントが起きるかを宣言しているのみです。
// domains/Character/actions.ts
import { actions, action } from '@fleur/fleur'
import { CharacterEntity } from "./types";
export const CharacterActions = actions(/* Redux Devtools用の識別子 = */ 'Characters', {
// action名: action<ペイロードの型>()
charactersFetched: action<CharacterEntity[]>(),
fetching: action.async<
{ characterId: string },
{ characterId: string },
{ characterId: string, error: Error }
>(),
})
fetching
とcharactersFetched
が並んでるのがモニョっとしますね。しかしCharacter Entityが降ってくるのはキャラクターをフェッチしたときだけとは限らないので、あくまでフェッチ状況を伝えるActionと、実際にフェッチされたEntityを伝えるActionを分けています。
他のEntityを正規化した時にCharacter Entityが取り出されて、他のドメインからcharactersFetched
が起きたときにCharacterActions.fetching.done
するのが適切か?通信状態も一緒にごまかさないといけなくて設計がちょっと大変じゃない?という感じですね。
Action名は過去形を使うようにしましょう。Redux Style guideや@f_subalさんのスライド でも言及されていますが、Actionを受け取った側がどういう処理をすべきなのかが伝わりやすくなります。
ただ一点、Redux Style Guideで述べられている「一つのActionで全ての関係Reducerが反応するようにすべき」という点には一概に賛同していません。
実はそのような構造にすると、特に大規模なアプリケーションにおいて「あるActionによってアプリケーションで何が発生するのか」が人間的に予測しづらくなり、実は適度な粒度でActionを連投した方が処理の流れが自明になることがあります。(VRoid Hubではエンティティ種毎にusersFetched
, charactersFetched
のようにactionを連投する形にしています。)
特にFleurでどうしても仕方なくStore側で副作用を持っている場合は、どういう副作用を起こすかによってActionを切り分けた方がよさそうです。
またそれが推奨されている理由のもう一つに、ReduxではActionの連投はパフォーマンスに良くないというものがあるそうですが、FleurではStoreからの変更通知はrequestAnimationFrame
でバッファリングされているため、あまり気にしなくてよいです。
Store
続いてStoreです。 Fleurにはclass-style StoreとreducerStoreがありますが、基本的にreducerStore
の利用を推奨しています。こちらは副作用を持てないStoreなので、どうしてもStoreで副作用が必要なときはclass-style Storeを利用します。(class-style Storeの書き方はこちらをご参照ください。)
// domains/Characters/store.ts
import { reducerStore } from '@fleur/fleur'
import { CharacterActions } from './actions'
import { CharacterEntity } from '../CharacterEntity/types'
interface State {
characters: { [characterId: string]: CharacterEntity | void }
fetching: { [characterId: string]: { fetching: boolean, error?: Error } }
}
export const CharacterStore = reducerStore<State>('Character', () => ({
characters: {},
fetching: {},
}))
.listen(CharacterActions.charactersFetched, (state, characters) => {
characters.forEach(c => state.characters[c.id] = c)
})
.listen(CharacterActions.fetching.started, (state, { characterId }) => {
state.fetching[characterId] = { fetching: true }
})
.listen(CharacterActions.fetching.done, (state, { characterId }) => {
state.fetching[characterId] = { fetching: false }
})
.listen(CharacterActions.fetching.error, (state, { characterId, error }) => {
state.fetching[characterId] = { fetching: false, error }
})
-
reducerStore<State>(storeName, initialStateFactory)
でStoreを宣言します。-
storeName
はアプリ内で一意の名前である必要があります。SSR時に吐き出すJSONの名前空間の識別に利用されますが、SSRなしの場合でも必須です。 -
initialStateFactory
はStoreの初期状態を返す関数を渡します。
-
-
ReducerStore.listen(action, callback)
でactionに対する処理を指定します。-
state
を直接変更していますが、これはimmerでラップされたdraftオブジェクトなので、実際のstateはイミュータブルに変更されます。- これは極めて強くて、特にReact Hooksとの組み合わせにおけるメモ化ではめちゃくちゃ楽にメモ化条件を設定することが出来ます。
-
- Store内で外部通信などの副作用を起こさないようにしてください。副作用はできるだけOperation層に集めてください。
Component
ではここまで書いてきたものをコンポーネントに繋いでいきます。
// pages/character.tsx
import React, { useCallback, useState, ChangeEvent } from 'react'
import { CharacterSelectors } from 'domains/Characters/selectors'
import { useStore, useFleurContext } from '@fleur/react'
import { UserSelectors } from 'domains/Users/selectors'
import { CharacterOps } from 'domains/Characters/operations'
import { API } from 'domains/api'
export const CharacterPage = () => {
// URLからテキトーにキャラクターIDを取ってくる
const characterId = 1
const { executeOperation, depend } = useFleurContext()
const character = useStore(getStore =>
CharacterSelectors.getById(getStore, '1')
)
const user = useStore(getStore =>
character ? UserSelectors.getById(getStore, character.user_id) : null
)
const handleChangeName = useCallback(
({ currentTarget }: ChangeEvent<HTMLInputElement>) => {
if (!character) return
depend(API.putCharacter)({ name: currentTarget!.value })
executeOperation(CharacterOps.fetchCharacter, character.id)
},
[character]
)
if (!character || !user) {
return <div>{/* いい感じのスケルトンを出す */}</div>
}
return (
<div>
<h1>
<input
type="text"
defaultValue={character.name}
onChange={handleChangeName}
data-testid="input"
/>
</h1>
<h2>
<a href={`/users/${user.id}`} data-testid="author">
{user.name}
</a>
</h2>
</div>
)
}
ここで出てきたAPIを解説します。
-
useFleurContext
- Operationを実行するためのexecuteOperation()
, DIのためのdepend()
, Storeの値の遅延取得のためのgetStore()
が入ったオブジェクトを返します。-
executeOperation(operation, ...args)
- 第一引数に渡されたOperationを実行します。 -
depend(obj)
- objを取得します。テスト時にobjをモックに差し替えることが出来ます。 -
getStore(Store)
- Storeのインスタンスを取得します。基本的にuseStoreで値をとってくるので余り使うことはないと思いますが、表示には関係ないけどStoreから値を取らないといけない場合に使います。
-
コンポーネント内の属性に出てくるdata-testid
は、後述するテストで利用します。
Selector
Selectorはこんな感じに用意してあげます。
// domains/Characters/selectors.ts
import { selector } from "@fleur/fleur";
import { CharacterStore } from "./store";
export const CharacterSelectors = {
getById: selector(
(getState, id: string) => getState(CharacterStore).characters[id]
)
}
- Component側のuseStoreでは
getStore
でしたが、selector内ではgetState
です-
Store#state
を取得してくるためです。 - Storeのインスタンス自体にアクセスする必要がある場合、
selector()
の代わりにselectorWithStore()
を使います。
-
Bootstrap
最後にアプリの立ち上げ部分を書きます
// app.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import Fleur from '@fleur/fleur'
import { CharacterStore } from 'domains/Characters/store'
import { UserStore } from 'domains/Users/store'
import { FleurContext } from '@fleur/react'
const app = new Fleur({
stores: [CharacterStore, UserStore]
})
const context = app.createContext()
window.addEventListener('DOMContentLoaded', () => {
const root = document.getElementById('#root')
ReactDOM.render(
<FleurContext value={context}>
<App />
</FleurContext>,
root
)
})
-
new Fleur
でFleurインスタンスを作ります-
stores
オプションにアプリ内で利用しているStoreを全て渡します。
-
Fleurでのテスト
ここからは今まで書いてきたOperation, Action, Componentに対してのテストを書いていきます。
Fleurのテストには@fleur/testing
というパッケージを利用します。
yarn add -D @fleur/testing
テストフレームワークにはjest (with ts-jest)を利用する想定をしています。
Contextのモック
まずテストのためのモックContextを作ります。
// spec/mock.ts
import { mockFleurContext, mockStore } from "@fleur/testing"
import { CharacterStore } from "domains/Characters/store"
const baseContext = mockFleurContext({
stores: [
// ここにアプリで使われるStoreをこの形式で突っ込む
mockStore(CharacterStore)
]
});
export const baseOperationContext = baseContext.mockOperationContext()
export const baseComponentContext = baseContext.mockComponentContext()
OperationとStoreのテストで利用するbaseOperationContext
、Componentのテストで利用するbaseComponentContext
をexportしておきます。
Operationのテスト
はい、ではまずOperationのテストをしていきましょう。
// domains/Character/operation.test.ts
import { CharacterOps } from './operations'
import { CharacterActions } from './actions'
import { UserActions } from 'domains/Users/actions'
import { API } from 'domains/api'
import { baseOperationContext } from 'spec/mock'
import { fakeRawCharacter } from 'spec/fakes/character'
describe('CharacterOps', () => {
it('キャラクターとユーザーのEntityちゃんと投げた?', async () => {
const context = baseOperationContext.derive(({ injectDep }) => {
// Storeの特定の状態を設定する場合は `deriveStore` をする
// deriveStore(AppStore, { credentialKey: 'mock' })
// API.getCharacterをモックする
injectDep(API.getCharacter, async (_, characterId) => fakeRawCharacter())
})
await context.executeOperation(CharacterOps.fetchCharacter, '1011')
expect(context.dispatches[1]).toMatchObject({
action: CharacterActions.charactersFetched
})
// expect(context.dispatches[1].payload).toMatchInlineSnapshot()
expect(context.dispatches[2]).toMatchObject({
action: UserActions.usersFetched
})
// expect(context.dispatches[2].payload).toMatchInlineSnapshot()
})
})
-
injectDeps(元のオブジェクト, モックオブジェクト)
によって、Operation内で.depend(...)
しているオブジェクト(関数)をモックすることが出来ます -
action.action
が関数なので一発でtoMatchInlineSnapshotしちゃうとちょっと信頼性に欠けます
context.dispatches
に発火されたActionの配列が入っているので、そのpayloadが意図した形になっているかどうかをチェックしていけば
Storeのテスト
Storeもテストしていきましょう
// domains/Characters/store.test.ts
import { CharacterStore } from './store'
import { CharacterActions } from './actions'
import { baseOperationContext } from 'spec/mock'
import { fakeCharacter } from 'spec/fakes/character'
describe('CharacterStore', () => {
it('エンティティがちゃんと保存されるか', () => {
const context = baseOperationContext.derive(({ deriveStore }) => {
deriveStore(CharacterStore, state => {
state.characters['10'] = fakeCharacter({ id: '10' })
})
})
const character = fakeCharacter()
context.dispatch(CharacterActions.charactersFetched, [character])
expect(
context.getStore(CharacterStore).state.characters[character.id]
).toEqual(character)
})
})
OperationContextからActionを投げて、意図したとおりのstateになっているかを検証します。
ここでは雑に1ケースしか書いてないですが、必要であればこの形式で書き足していきましょう。
その際、テストケース毎にbaseOperationContext.derive()
で複製したcontextを使うことを推奨しています。
deriveは#operationのテストで書いたように、Storeの状態を派生させる事ができます。前提状態があるテストを書く場合に利用してください。
const context = baseOperationContext.derive(({ injectDep }) => {
// オブジェクトはshallow-mergeされる
deriveStore(CharacterStore, {
characters: {
'10': fakeCharacter();
}
})
// Deep-mergeしたい時はコールバックを使う
deriveStore(CharacterStore, (state) => {
state.characters['10'] = fakeCharacter()
})
});
Componentのテスト
最後にComponentのテストです。 コンポーネントのレンダリングには@testing-library/react
を利用します。
import { render, getByTestId, fireEvent } from '@testing-library/react'
import { TestingFleurContext } from '@fleur/testing'
import React from 'react'
import { baseComponentContext } from 'spec/mock'
// ... 略
describe('Character', () => {
const mockedContext = baseComponentContext.derive(({ deriveStore }) => {
deriveStore(CharacterStore, state => {
state.characters['1'] = fakeCharacter({
id: '1',
name: 'Haru Sakura',
user_id: '2'
})
})
deriveStore(UserStore, state => {
state.users['2'] = fakeUser({
id: '2',
name: 'Hanakla'
})
})
})
// Case.1 キャラクターの情報ちゃんと出てる?
// Case.2 APIリクエストちゃんと飛ぶ?
})
baseComponentContext.derive
によりStoreの状態を設定したContextを生成します。
これを次のテストケースに食わせてあげると、特定の状態下で正しくコンポーネントが動くかをテストできます。
// Case1
it('キャラクターの情報ちゃんと出てる?', async () => {
const context = mockedContext.derive()
const tree = render(
<TestingFleurContext value={context}>
{/* <なんとかRouter url='/characters/1'> */}
<CharacterPage />
{/* </なんとかRouter> */}
</TestingFleurContext>
)
expect(tree.getByTestId('input').value).toBe('Haru Sakura')
expect(tree.getByTestId('author').getAttribute('href')).toBe('/users/2')
expect(tree.getByTestId('author').innerHTML).toBe('Hanakla')
})
mockedContext.derive()
でテストケース用のContextを初期化し、TestingFleurContext Component
に与えます。これにより、内部のFleur Contextをモックすることが出来ます。
あとは[data-testid]
を元に要素を探して適切な値が出てるかを調べてるだけですね。
TestingFleurContext
で囲うということだけ覚えておいてください。
次にAPIリクエストのテストをします。
// Case2
it('APIリクエストちゃんと飛ぶ?', async () => {
const apiSpy = jest.fn(async () => void 0)
const context = mockedContext.derive(({ injectDep }) => {
injectDep(API.putCharacter, apiSpy)
})
const tree = render(
<TestingFleurContext value={context}>
<なんとかRouter url='/characters/1'>
<CharacterPage />
</なんとかRouter>
</TestingFleurContext>
)
fireEvent.change(tree.getByTestId('input'), {
target: { value: 'Haru' }
})
// Wait for request
await new Promise(r => setTimeout(r))
expect(apiSpy).toBeCalledWith({ name: 'Haru' })
expect(context.executes[0]).toMatchObject({
op: CharacterOps.fetchCharacter,
args: ['1']
})
})
- Operationのテストでも登場した
injectDep
がここでも登場します。
Componentから外部通信を起こすようなパターンの設計をしている場合、ここでdepend
/injectDep
を使うことで関数をモックすることが出来ます。 - Componentから発火されたexecuteOperationの内容は`context.
裏話
Fleurの生まれ
FleurはDelir
というWeb製映像編集ソフトを開発していく過程で生まれました。
当初はFlux Utils
を使って構築されていましたが、直接的なStoreへの依存や、Action CreatorからStoreを触れないのはComponentが表示以上の責務を負わなければならないなどの問題があり、より適切な移行先を探す中でFluxibleにたどり着きました。Reduxはかなり要件のアンマッチがあり採用しませんでした。
- Storeに副作用が持てない
- JSONを扱うのが基本
- じゃあレンダリングエンジンのインスタンスはどこでもつのか? Component側か?アプリケーション全体のあらゆるイベントを受け付けないといけないのにComponent? React Context使ってインスタンス配っても状態変化の予測がつかない狂気になるだけじゃん?
- Storeに突っ込むのが保守性・予測性が良い。ならReduxは使えない…
- レンダリングエンジンが自身で副作用を発してもComponent側からはただのStoreの変化として扱える、合理的。
- 標準で非同期処理に対応していない
- 型付けのためだけのコードが多くてしんどい
- ActionNamesとActionの型定義が離れてるの果てしなくやりづらい
しかしFluxibleも2016年くらいのときには既につらみを抱えていました。TypeScriptの台頭です。
Fluxibleは上記の要件的には完全に一致しており、コードの読み書きにもほとんど困らないAPIでしたが、こと型をつけるという文脈ではかなり無理のあるAPIになっていました。(pixivFACTORYに所属していた時にFluxibleを採用し型定義を自前で行いましたが、相当しんどいものでした。)
そこでFluxibleを元に、より現代的な設計パターンやコードの書きやすいAPIを取り入れたFluxフレームワークを作る、という目的でFleurが誕生しました。
- 型推論フレンドリー
- コードの書き心地を良くする
- React Hooks対応
- 非同期処理対応 (Fluxibleは対応済み)
- immer.js組み込みのStore
- 大体のケースで使ったほうが良い。 まだImmutable.jsが良いとかImmer.jsがES5で使えない思ってる人いないよね?
- パフォーマンス上のボトルネックにならない
- Reactの再レンダリングによって映像レンダリングのFPSが落ちる。UXに如実に影響を与えるのでこれは必要な要件だった。
さらに、本業で得たSSR周りや一般的なWebアプリケーションに対する設計に関する知識を取り込み、通常のWebアプリケーションやSSR環境でも利用可能なフレームワークとしてキメキメにしていきました。
そういう経緯があり、Fleurは2019年までのSPA用ライブラリとしてはn番煎じをキメ込んで非常にまとまりが良く保守性・汎用性の高いフレームワークに仕上がりました。
SSR対応
SSR要件もちゃんと気にかけており、SSRを行うデモアプリを運用して、Apache Benchで現実的じゃない量のアクセスをかけてメモリリークが起きていない(GCでちゃんとメモリが開放されること)を確認したりしてします。
今回のサンプルにも載せられなかったんですが、ルーターも用意されています。
最後に
言い訳させてください! 完全攻略ガイドと言っておきながら体調不良によりNext.jsとかSSRとかに結局触れられませんでした!そこらへんは後日別記事でやるかもしれません(この記事のウケがよかったらね)
本記事で書いたコードはhanakla/advent-calendar-fleurにまとまっています。(実は雰囲気コードなので全部のテストは通ってないんだけどね)
とりあえず自分の中では2019年までのSPA設計のまとめとしてはいい感じのフレームワークになっているかなと思います。残念ながら社内プロダクトでの採用の機会はまだないんですけどね〜! 来年は2020年なのできっとさらに色々良くなったFleurをお見せできるかと思います!
もしよろしければ、「雑にFleur使ってみた」「作者が怠惰だからオレがFleur完全攻略してみた」など、Fleurに対する感想、不満、オレの設計語りなどインターネットに放流してください! Fleur開発に対するモチベーションと設計の洗練度が上がるので! Twitterハッシュタグは #fleurjs GitHub Topic / Qiita tagは fleur-js です!
Q & A
2019年的?
2019年的です。2020年的ではないという意味でもあります。色々情報を集めてはいますがまだよくなれる余地がありそうな気がしています。マイグレーションしやすいことは考えていますが、Breaking changeは入れていくと思います。
Next.jsで使える?
使えます。いますぐyarn create fleur-next-app <your-app-name>
しましょう。
本記事で解説したのとほぼ同等の構成のファイル郡でアプリ開発をおっ始める事ができます。
そのうちNext.js + Fleurでアプリを作る記事でも書こうかと思います。
画面毎にStoreを分けるのどう思う?
2021年1月追記: この用途のために@fleur/lysというライブラリを2020年末にリリースしました!
いいと思う。ただその場合この記事で解説したdomain毎の分割だけだと治安が悪くなるので、page毎にStoreを入れるディレクトリを上手く分ける必要はありそう。ただし現バージョンのFleurはあまりそれがやりやすい構造になってないので、それについては今後の課題かなと思っています。
useReducer
を薄〜くラップしたライブラリがあるとそういう「アプリ全体には影響ないけど、ある特定のページ以下でめっちゃグローバル」みたいなやつを切り出せるんだよな〜とも思っています。これも考えてる。既にいくつかそれらしいライブラリがあった気もしている。
なんでOperationとActionが分かれてるの?
ActionとOperationが1ファイルに混じってごちゃごちゃするのが嫌だったのと、Re-ducksパターンによる影響と、実際ActionとOperationってそんなに綺麗に一対一になるか?という気持ちに決着がつけられていないためです。typescript-fsaはその点すごくシンプルですね。ActionとOperationが透過的に扱えるRedux故の特徴です。 Fleurも何かしら考えたほうがいいよな〜とは思っています。
middlewareない?
ない。middlewareを入れると実装の見通しが悪くなるので、簡単に入れられる仕組みを入れたくない。どうしても必要だったらcontextをラップしてくれ。(Redux DevTools対応はその方式でやってる)
const yourMiddleware = (context: AppContext) => {
const executeOperation = context.executeOperation
const dispatch = context.dispatch
context.executeOperation = (op, ...args) => {
// なにがしの処理をする
context.dispatch(MiddlewareActions.hoge, {})
return executeOperation(op, ...args)
}
context.dispatch = (action, payload) => {
if (action.name.indexOf('.started')) {
// ローディングを出したりする
}
return dispatch(action, payload)
}
return context
}
const app = new Fleur();
const context = yourMiddleware(app.createContext())
Fleurのselectorはmemoizeしてる?
してない。 関数一発でmemoizeしてもキャッシュヒット率がたかが知れてるのでしてない。
代替案としてはReactのuseMemo
を使う形に寄せて欲しい。useMemoならコンポーネントの文脈に沿ったよりヒット率の高いmemoizeができる。
2021年1月の追加情報: useStore
の第二引数にequality checkが挟めるようになり、標準でshallow equalsが入ったので、不要な再レンダリングが発生しづらいように改善されました!
reselectのコードを読んだことがあればわかると思うけど(ここらへんな)、あるselectorが複数回異なる引数で呼ばれるような場合、reselectのmemoizeはまともに機能しないっぽく見える。ある引数でmemoizeしても次に異なる引数でselectorが呼ばれればキャッシュは破棄される。そして真面目にプロダクトコード書いてたらこれは普通に発生する。
EcmaScriptのRicher keys - compositeKeyが入ればWeakMapで今よりはマシなmemoize機構を作れそうだけど、FleurもReduxも別々の問題でキャッシュヒット率には難がありそうな予感がしています。
なんでComponentからdispatch出来ないの?
dispatch
はStoreに対するかなり低レイヤーな操作です。もしこれをComponentから使えるようにしてしまうと、アプリの構造化が属人的になりやすくなってしまいます。(どこからどういう粒度でdispatchするかを考える余地が生まれてしまう)
とはいえ昨今のイケてるフロントエンドを見ているとReact SuspenseとかuseAsync
などを使ってComponent側から通信を起こすケースがありがちなので、そろそろ許しても良いかもしれない…けどアプリが大きくなってもそれを続けられるのか…?小規模アプリだから許されてるのでは…? そもそもその手のアプリはFluxライブラリ使ってないんじゃ…? というところで悩んでいます。
俺はVue派なんだが?
Vue版も作ろうか悩んでる、やればVuexより型は強くなる。ただ内部事情としてimmer.js
とVueは相性が悪いとかいう次元じゃないのでFleurの書き直しかMutableStoreの対応が必要になる。
あとMobxとかVuexとか色々見ていて「Fleurは本当にこのままの設計でいいのかな〜?」という気持ちもあるのでそこらへん全てに踏ん切りがついたらやるかもね
(たぶんVueの人たちVue公式以外のもの使いたがらなさそうという気がするので暇すぎてひまわりになったら🌻やる)
2021年1月の見解: Vueの君たちにはVuexとかNuxtとかあるから大丈夫だよ!!!!! 知らんけど!!!!!
パフォーマンスどんなもん?
(これは2021年1月3日時点の情報になっています)
ちょっと極端なケースで計測しているのですがFleur vs Reduxはこんな感じです。
Redux
Fleur
Fleurは時間あたりのdispatchに対してかなり最適化を効かせているため、素で使うとdispatchまわりの再レンダリング回数は少ないです。(連続でdispatchしてもパフォーマンスが悪化しづらい) 「限界まで再レンダリングを減らしている」とだけ認識してもらえれば大丈夫です。
export default
してないの?
なんで君のサンプルコード- Named exportしておくとVSCodeのimport候補に出やすくなるから
- 後から「あっこの名前よくなかったわ」って時にVSCodeの自動リファクタリングで一発で名前を変えたいから
(default importされたやつは名前変更で一発で変わってくれない) - default importした時に、人によってimport物にどういう規則で名前つけるか揺れてほしくないから
- import名に対してコーディング規約を考えるのしんどいから最初から適切な名前ついていてホシイ 😟 🥺
Discussion