YSNHatenaBlog

主にアプリやWebサービス開発について

Redux Toolkitがすごい便利だった

画面遷移しても状態保存しておこうと思い、再度reduxを見直していたが、これを見つけてちょっと感動した。

redux-toolkit.js.org

reduxの構成にしたい人はこれ入れておくとよさそう。

action, reducer, selectorの記述が少ない

reduxは何と言ってもコードの記述量が増えるのがめんどくさいのだが、とてもスマートに書ける。

import { createSlice, PayloadAction } from '@reduxjs/toolkit'
import { CourtDoc } from 'models/court'
import type { RootState } from 'store'

type Mode = 'text' | 'location'
type Geo = {
  lat: number
  lng: number
}

interface HomeState {
  mode: Mode
  zoom: number
  center: Geo
  courts?: CourtDoc[]
}

const initialState: HomeState = {
  mode: 'text',
  zoom: 11,
  center: { lat: 35.681236, lng: 139.767125 },
}

export const homeSlice = createSlice({
  name: 'home',
  initialState,
  reducers: {
    changeMode: (state, action: PayloadAction<Mode>) => {
      state.mode = action.payload
    },
    changeZoom: (state, action: PayloadAction<number>) => {
      state.zoom = action.payload
    },
    changeCenter: (state, action: PayloadAction<Geo>) => {
      state.center = action.payload
    },
  },
})

export const { changeMode, changeZoom, changeCenter } = homeSlice.actions

export const selectMode = (state: RootState): Mode => state.home.mode
export const selectZoom = (state: RootState): number => state.home.zoom
export const selectCenter = (state: RootState): Geo => state.home.center
export const selectCourtDocs = (state: RootState): CourtDoc[] | undefined =>
  state.home.courts

export default homeSlice.reducer

actionのstringを自分で書かなくても、 createSlicenameと関数名から自動でいい感じに生成してくれる。 reducers の書き換えをしてもglobalに影響が出ないようになっている。

reselect

まだやってないけど createSelector でSelectorを作ると reselect が使われてパフォーマンス上がる模様。 createSelector | Redux Toolkit

TypeScript対応

型がちゃんと付く。

フラグ一個でredux-devtools-extensionがつく

export const store = configureStore({
  reducer: {
    firebase: firebaseReducer,
    firestore: firestoreReducer,
    home: HomeReducer,
  },
  devTools: true, // <- これだけ
})

副作用の対応

前述のcreateSliceのところで、

import { createAsyncThunk, ... } from '@reduxjs/toolkit'
import { search } from 'models/search'
...

export const searchByText = createAsyncThunk(
  'home/searchByText',
  async (params: { text: string; hits: number }) => {
    const courtDocs = await search(params.text, params.hits)
    return courtDocs
  }
)

export const homeSlice = createSlice({
  ...,
  extraReducers: {
    [searchByText.fulfilled.type]: (
      state,
      action: PayloadAction<CourtDoc[]>
    ) => {
      state.courts = action.payload
    },
  },
})

...
export default homeSlice.reducer

これでいい。

すごい便利だ...。まだかじっただけなので、もうちょっと調べる。

google-map-reactから@react-google-maps/apiに乗り換え

もともと google-map-react を使っていたのを @react-google-maps/api に変更。

google-map-react はダウンロード数が多く、当初必要だった要件を満たしそうだったので導入したものの、デフォルトのマーカーへの変更を考えたときに、

new maps.Marker({
  position: { lat, lng }
  map,
})

のようにライブラリを直接呼び出す必要があり、Reactっぽくなく使いにくかった。 クリックイベントも取得しにくいので、他ライブラリを調査することに。

google-maps-react というそっくりな名前のパッケージもあったが、あまりメンテされてなさそうなので不採用。

結局こちらを使うことに。

www.npmjs.com

<GoogleMap
mapContainerStyle={{
  width: '100%',
  height: '100%',
}}
onIdle={onMapMoved}
onLoad={onLoaded}
>
{courtDocs.map((courtDoc) => {
  return (
    <Marker
      key={id}
      position={{ lat, lng }}
      onClick={() => onClickMarker(id)}
    />
  )
})}
</GoogleMap>

これなら Marker をReact Componentとして扱える。

吹き出しInfoWindowコンポーネントを使う。 これも Marker と同じように GoogleMap の子として追加すればよい。 吹き出しは✗で消せるので、消えたときに InfoWindow も取り除かないと再表示がうまくいかなかった。

23区分だけデータ入れ終わった

tennico.app

公私共に忙しかったので進捗悪かったけど、とりあえず23区だけ。 市区町村入れるとあと44ありそう...。

ただここでデータの形式についてちょっと考えたいかもしれない。 特に料金が複雑で検索パラメータとして入れにくい。

どう探したいかを考えながらデータ決めるといいだろうか。

自作サービスにデータを入れていっている

tennico.app

都内のテニスコートを探してはデータ入力していっている。

改めて思ったのはテニスコートの料金って結構複雑であるということ。

  • 時間帯
  • 季節
  • コート
  • 地域住民かどうか
  • 照明利用有りか

などで料金は変わるし、予約できる時間単位(1時間、2時間など)も重要。

設計段階で予想はされていたので、一旦乱暴に文字列で突っ込んでいる状況(URL参照、みたいのもある...)だが、一通りデータ入力し終わったら、料金のデータ構造どうするか再考したいところ。自分がユーザなら料金と地域住民じゃなくても借りられるかを検索フィルタできるのは結構嬉しい。

あとはデータのメンテナンスはどうするか。当然自分だけでは監視しきれないので、ユーザによる報告か直接編集で対応する感じかな。最初は報告ベースかな。

Firestoreの住所のfieldを整理

いままではプロトタイプの意味も含めて住所は1 fieldに格納していたけど、都道府県、市区町村、残りで分けるようにしてみた。もっと細かくもできたかもしれないけど、どうやって探すかを考えればそこまで詳細じゃなくてもよいでしょうと。入力も大変だし。

いったん最低限のことはできたと思うので、ここからしばらくはデータを入れていこうと思う。

github.com

Firestoreのルールのテストを書いた

Firestore rulesのテストを書いていく。

initializeAdminAppで取得したappでfirestoreに書き込む際、Timestampの型はfirebase-admin由来でないとエラーになった。

Value for argument "data" is not a valid Firestore document. Detected an object of type "Timestamp" that doesn't match the expected instance (found in field "createdAt"). Please ensure that the Firestore types you are using are from the same NPM package.)

Timestampを@firebase/rules-unit-testingではなくfirebase-adminから型を取ったら直った。

次のエラー。

(node:18029) UnhandledPromiseRejectionWarning: Error: FIRESTORE (8.2.5) INTERNAL ASSERTION FAILED: Unexpected state
(node:18029) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). (rejection id: 33)

stackoverflow.com

jest --env=node

にする。

また、作成したapp(adminもそうでないのも)は都度delete()で消さないとCI上だとjestが終了しなくなる。 jest --forceExit を使うと終了させることができるが、気持ち悪いなら都度掃除する。

テストコードの最初はこんな感じになった。

import * as firebase from '@firebase/rules-unit-testing'
import * as admin from 'firebase-admin'

const projectId = 'rules-courts-test'

beforeEach(async () => {
    firebase.loadFirestoreRules({
        projectId,
        rules: fs.readFileSync('firestore.rules', 'utf8'),
    })

    const adminApp = firebase.initializeAdminApp({ projectId })
    // adminAppを使ってテストの初期状態となるデータを書き込んでいく
    await adminApp.delete()
})

afterEach(async () => {
    await firebase.clearFirestoreData({ projectId })

})

また、公式ガイドだとassertSucceeds, assertFailsはawait無しで書いてあるので、もしやいらない?と思ったが、awaitしないと正しいテスト結果にならなかった。

test('read shoud be succeeded', async () => {
    await firebase.assertSucceeds(
        app.firestore().collection('courts').doc('Slsnk6XjulO3ndipFjlY').get()
    )
})

Youtubeでもawait入れてるしよさそう。

www.youtube.com

実際の差分はこうなった。

github.com

Firestoreルールのテストの書き方を再調査

Firestoreルールのテストの書き方を復習がてら再調査。

firebase.google.com

カバレッジの取得。そういえばできた気がする。

http://localhost:8080/emulator/v1/projects/<project_id>:ruleCoverage.html

Firestoreに限らないかもしれないが、最初のデータセットアップが結構めんどくさい。 本家にもAdminAppを使うぐらいしか解を書いていない。

テストのセットアップが非常に複雑である

firebase.google.com

予め作った、データセットが定義されたファイルからインポートできたらいいのになぁと思っているのだが...

ちなみにテスト前にデータのimportはできるかもしれない。が、beforeEachで都度インポート、みたいなことはできなそう。 firebase.google.com

いろいろ調べたが、やはり都度AdminAppでやるしかなさそう。

基本的には*.test.tsごとにプロジェクトIDを指定し、beforeEachでデータセットアップ、afterEachでclearFirestoreDataするようにしたい。 beforeEachごとにプロジェクトのIDを更新するというのが前あったが、使い捨てプロジェクトがガンガン生成されるためちょっときれいじゃない感がある。他に方法なさそうならこれでもいいけど。

目的と直接関係ないが@firebase/rules-unit-testingにこんなのがあった。

withFunctionTriggersDisabled

github.com

この引数のクロージャの中でFirestoreを書き換える分にはFirestore Triggerが発火しないらしい。でも実装見てる感じ、クロージャの中実行前後でエミュレータのWeb API叩いて全体でdisableさせてるみたいなので、並列でテストが実行されていたりするとおかしなことになりそう。