暇人の寝室
技術系の記事や読書・アニメの感想などを投稿します。
プログラミング
前回は開発環境の構築までを紹介したので、今回はフロントであるReactの実装について紹介する。
基本的な流れはViewからアクションが発行されるとReducerに通知され、Stateが更新される。
非同期リクエストはViewとReducer間のMiddleware機能を使い、ReduxSagaを発行させ、Axiosを利用してLaravelのAPIを叩く。
返ってきたレスポンスをReduxSagaからアクションを発行することでReducerに通知する。
ページとなるJSXはpageに、それらを構成するコンポーネントはcomponentsに分けて配置した。
また、Reduxに関連するファイルはreduxにまとめた。
これらはトップダウン、ボトムアップともにソースを辿りやすくするための配置である。
ReactReduxを利用し、アプリ全体で利用する状態はreducerに、コンポーネント単位でしか利用しない状態はReactのstateを利用した。
表示しているページに関係ないデータをreducerで抱えるのを避けるためだ。
Reduxを使うとAction、Reducer、ActionCreatorとファイルが増えていくので、下記のように1ファイルにまとめる形をとった。
ReduxSagaまでまとめるとファイルの行数が増えて帰って見づらくなるので、ReduxSagaは別ファイルとした。
import actionCreatorFactory from 'typescript-fsa';
import { reducerWithInitialState } from 'typescript-fsa-reducers';
import Habits from '../../models/Habits';
// Action Creator
const actionCreator = actionCreatorFactory('Habit');
export const HabitsActions = {
getHabits: actionCreator<void>('getHabits'),
setHabits: actionCreator<Habits>('setHabits'),
addHabit: actionCreator<object>('addHabit'),
updateHabit: actionCreator<{ habitId: number; values: object }>(
'updateHabit',
),
removeHabit: actionCreator<number>('removeHabit'),
formInitialize: actionCreator<number>('habitFormInitialize'),
};
// Reducers
export const habitsReducer = reducerWithInitialState(new Habits()).case(
HabitsActions.setHabits,
(state, payload) => {
return state.set('items', payload.getList());
},
);
例えば、習慣に関するモジュールを記述したのが上のコードである。
typescript-fsa-reducerを利用したので、1ファイルですっきり見通せるようになった。
例えば習慣を表現するモデルが下記。
import { List, Record } from 'immutable';
import { Dayjs } from 'dayjs';
import { JSObject } from '../types/Common';
import dayjs from '../lib/dayjs-ja';
import HabitRecords from './HabitRecords';
export class Habit extends Record<{
id: number;
habitName: string;
repeatType: string;
repeatValue: number;
startedAt: Dayjs;
targetTime: number; // 単位は「分」
timeOfDay: string;
consecutiveDays: number;
//consecutiveWeeks: number | null;
}>({
id: 0,
habitName: '',
repeatType: 'day_of_week',
repeatValue: 127,
startedAt: dayjs(),
targetTime: 0,
timeOfDay: '',
consecutiveDays: 0,
//consecutiveWeeks: null,
}) {
static fromResponse(response: JSObject): Habit {
const params = { ...response };
return new Habit(params);
}
isCompleted(date: Dayjs, habitRecords: HabitRecords): boolean {
return habitRecords
? habitRecords
.filterById(this.id)
.filterByCompletedAt(date)
.getList().size > 0
: false;
}
}
export default class Habits extends Record<{
items: List<Habit>;
}>({
items: List(),
}) {
static fromResponse(response: JSObject): Habits {
const params = { ...response };
params.items = List(
params.habits.map((item: JSObject) => Habit.fromResponse(item)),
);
return new Habits(params);
}
getById(id: number): Habit | undefined {
return this.get('items').find(value => value.id === id);
}
getList(): List<Habit> {
return this.get('items');
}
}
非同期リクエストで受け取るレスポンスはLaravel側のモデルと同じ単位で扱うことになるので、React側でもImmutableJsを用いてモデルを作成した。
モデル内にフィルタリングなどの処理を含められるので、Reactのrender()内でごちゃごちゃ弄る必要がなくなる。
(途中からこのルールを逸脱して見づらいクソコードになってしまったのは反省すべきところ。)
Laravel側ではReactを表示するindex.htmlのみを返し、ルーティングはreact-routerで行った。
ログイン状態に応じてページを遷移する、というのをReact側で実装したかったため。
ReactRouterのSwitchが返されるコンポーネントのcomponentDidMount()でログインチェックを行い、結果に応じてルーティングを行った。
ReduxSagaをMiddlewareに登録して、Axiosを利用してLaravelにリクエストを送る形を取った。
AxiosはデフォルトでCookie周りを処理してくれるので認証に関して特に設定した点はなかった。
基本的にフォーム入力を行う箇所はredux-formを利用した。
reducerをフォーム用に使うことなく簡単にフォームを構築できるためである。
未実装だが、エラー表示などもフィールドに与えられたプロパティから簡単に実装できるので、フォームを作る場合は有用なライブラリだ。
テストにはJestを用いた。
Viewに関してはスナップショットを取って、意図しない変更が起きないようにした。 sそれに加えてハンドラが機能しているか、stateによるフィルタリングが適切に機能しているかなどをコンポーネントやページ毎にテストを行った。