はじめに
自己紹介
私は関西で主にフロントエンド開発に携わっておりますオオツカと申します。
最近はリモートワークが増えて自宅で一人寂しく仕事をしているので、もっと人と関わりたいと思いながらの毎日を送っている次第でございます。
WEBに関わるキャリアとしてはweb制作会社で1年弱、現在の開発会社で1年と少しになります。
現在のプロジェクトではNuxt.js(vue.js)で主にフロントエンドのUI周りの調整を行っています。
まだまだ得意とまでは言えるレベルではありませんが、JavaScript、Reactあたりはプロジェクトとしていくつか経験をさせていただきましたので他の技術よりかは分かるかな?というレベル感です。
最近はコロナの影響や、子供と遊んだりであまり外のイベントには出れていないのですが、少し前まではもくもくエキスパートというもくもく会を開催しておりました。コロナがもう少し落ち着いたらまた開催をしたいと思いますので、その際は皆様是非ご参加くださいませ😀
今回の記事について
- 過去に自分がどのような失敗を行なったのか?そして今回の個人開発でどのように改善を行なったのか?というところを自分の備忘録として残すことが目的です。
なおこれからフロントエンドを触る方々に対しても多少なりお役に立てればという気持ちで書いてみました。 - 主にフロントエンドの事について記述させていただきました。
APIとして実装をしたRailsに関しては特に雰囲気で書いてしまっているため、説明は控えさせていただきます。 - 誤った認識、もしくは「もっとこうしたらどう?」みたいなところがございましたらコメントでご指摘くださいませ。
今回作った『TATEKAE』アプリについて
アプリURL:https://www.tatekae.work/
※ 私個人が使うために作ったものになるので、動作の確認等であればテストユーザーを使って見ていただければと思います。バグがあったらこっそりDMで教えてください🙇♂️大丈夫なはず。。
※ スマホで使う時に一番使いやすいように画面実装を行なっております。
↓アカウント
https://twitter.com/keisei_otsuka
なぜ作ったのか?
理由① 自分が使いたいアプリを作りたかったから
前提として私は奥さんから毎月決まった額のお小遣いをもらって生活をしています。
その中で私の住んでいる所は割と田舎でクレジットカードを取り扱っているところが少ないのですが、家庭での必要な買い物を私のお小遣いから『立て替え』をする機会が多く、そのお金の管理をもう少し楽に出来ないかなという事で、今回のアプリを開発しました。
このアプリはどのような事を解決するのか?
主に下記のような問題と解決をすることができると考えています。
-
買い物をした後のレシートが溜まってしまう。
- アプリに記録をすることでレシートを溜めることがない
-
買い物をした時に毎回家族に連絡をするのがめんどくさい。
- LINE NOTIFYと連携させる事によって、買い物の登録、指定した買い物をまとめた請求をした時にLINEで通知をすることができる。
-
過去の買い物、請求の記録を見たい時
- アプリ内で記録を見直すことができる。
-
etc...
理由② 過去に参画をしたプロジェクトでの反省を活かす
こちらが一番の理由になりますが、私が今まで参画をしたプロジェクトで自分の技術力不足や知識不足でうまく出来ていなかった箇所の改善をしたかった。個人開発であれば納期もないのでリファクタリングにたくさんの時間を避けるため。
主なサービスの構成について
-
フロントエンド => React(Next.js)、TypeScript
-
フロントエンドのデプロイ先 => Vercel
-
バックエンド => Ruby on Rails
-
バックエンドのデプロイ先 => Heroku
過去の失敗と改善について
問題① 定数の管理があまり出来ていなかった
こちら基本的なことかと思うのですが、自分があまり出来て部分があったので明らかに同じものを使う場合は定数のファイルに配置をする事にしました。直越なんでも書いてしまうと後で変更をする時に変更忘れがあったりするのはやはり恐いですね。。
export const LABEL_NAME = {
NAME: '名前',
EMAIL: 'メールアドレス',
PASSWORD: 'パスワード',
PASSWORD_CONFIRMATION: 'パスワードの再確認',
} as const;
export const USER_FORM = {
NAME: {
LABEL: LABEL_NAME.NAME,
ID: 'name',
},
EMAIL: {
LABEL: LABEL_NAME.EMAIL,
ID: 'email',
},
PASSWORD: {
LABEL: LABEL_NAME.PASSWORD,
ID: 'password',
},
PASSWORD_CONFIRMATION: {
LABEL: LABEL_NAME.PASSWORD_CONFIRMATION,
ID: 'passwordConfirmation',
},
} as const;
あと個人的によかったなと思ったのがAPIとして使用をしているRailsのエンドポイントをオブジェクトとしてまとめたことはよかったかなと思いました。
const auth = 'auth';
const claims = 'claims';
const shops = 'shops';
const shoppings = 'shoppings';
const settings = 'settings';
export const END_POINT = {
DEVISE_TOKEN_AUTH: {
REGISTRATIONS: {
CREATE: `/${auth}`, // ユーザー新規登録
NEW: `/${auth}/sign_in`, // ユーザーログイン
},
},
SHOPS: {
INDEX: `/${shops}`, // ショップ登録
CREATE: `/${shops}`, // ショップ登録
},
SHOPPINGS: {
INDEX: `/${shoppings}`, // ショップ一覧
CREATE: `/${shoppings}`, // 買い物登録
SHOW: (id: string) => `/${shoppings}/${id}`, // 買い物詳細
EDIT: (id: string) => `/${shoppings}/${id}/edit`, // 買い物編集
UPDATE: (id: string) => `/${shoppings}/${id}`, // 買い物編集
DESTROY: (id: string) => `/${shoppings}/${id}`, // 買い物削除
},
CLAIMS: {
INDEX: `/${claims}`, // 請求一覧
CREATE: `/${claims}`, // 請求登録
NEW: `/${claims}/new`, // 請求登録
UPDATE: (id: string) => `/${claims}/${id}`, // 請求受領
DESTROY: (id: string) => `/${claims}/${id}`, // 請求解除
SHOPPINGS: (claimId: string) => `/${claims}/${claimId}/${shoppings}`, // 請求内訳一覧
},
SETTINGS: {
INDEX: `/${settings}`, // 設定情報表示
UPDATE: `/${settings}`, // 設定情報の更新
},
} as const;
END_POINT
というオブジェクトのkeyはRailsのコントローラー定義を表すようにして、オブジェクトの最終的なvalueに当たるのが対象のエンドポイントになるようにしてみました。
これでRailsがあまり分からない人でも直感的にルーティング周りが分かるように出来たのは個人的には良いやり方なのかなと思っています。
問題② コンポーネントの命名について
私がコンポーネントを作っていく時に『ボタン』を作成する時に命名として、下記のように作成していました。
├── button
│ ├── Button.tsx
まんまですね。
ですが、こちらを上記のように定義をすると名前衝突
という問題が起きる可能性があります。
例えば別でMaterialーuiのボタンのコンポーネントの名前もButtonというコンポーネント名になっており、
こちらのボタンの名前と被ってしまいます。
なお、でもこちらの命名についてVue.jsのスタイルガイドでも説明がされております。
なので今回は基本となるコンポーネントには接頭辞としてBaseという接頭辞をつけるようにしました。
├── button
│ ├── BaseButton.tsx
今回あまり派生のコンポーネントは作成していないのですが、そこから派生をさせる時は、~~~Button
のように派生をさせていけば分かりやすいのかなと考えています。
問題③ コンポーネントのディレクトリー構造について
下記のコンポーネントはいくつか省略をしていますが、過去に大体がこのようなディレクトリーで構造で切っていました。(こちら命名が少し良くない箇所があるかと思いますがご容赦ください)
├── atoms
│ ├── ALink.tsx
│ ├── AuthUserIcon.tsx
│ ├── BackButton.tsx
│ ├── Button.tsx
│ ├── CheckBox.tsx
│ ├── CloseButton.tsx
├── molecules
│ ├── CList.tsx
│ ├── NoUserProgress.tsx
│ ├── Progress.tsx
│ ├── TermOfUseContent.tsx
│ └── UserSelect.tsx
├── organisms
│ ├── RegisterInfoList.tsx
│ ├── ReserveModal.tsx
│ ├── UserForm.tsx
│ ├── UserInformation.tsx
│ ├── common
│ │ ├── ConfirmListModal.tsx
│ │ ├── ConfirmModal.tsx
│ ├── family
│ │ └── FamilyListInfo.tsx
│ ├── reservation
│ │ ├── MonthCalendar.tsx
│ │ ├── NoUserReservationConfirmMiniInfo.tsx
│ │ └── ReservationListInfo.tsx
│ └── user
│ ├── FamilyEditForm.tsx
│ ├── UserEditForm.tsx
│ ├── UserEditListInfo.tsx
│ ├── UserListInfo.tsx
│ └── UserResisterForm.tsx
└── layout
├── CommonWrapTemplate.tsx
私はフロントエンドで使う画面の部品を作っていく中でアトミックデザインのような考え方でコンポーネントを分割をしていました。(アトミックデザインについてはいろんな記事で紹介をされているのでここでは説明は割愛させていただきます)
ここで上記のディレクトリー構造について私が感じた問題点については下記です。
- 新しいコンポーネントを作成する中でこれは
atoms
なの?それともmolecules
なの?みたいなことを考えることが多くなってしまった。 - コンポーネントがpageに依存をしているものなのか、どこでも使って良いコンポーネントなのか分からない
こちら色んな記事を参考にしたい自分で考えてみたりで下記のようになりました。
├── common
│ ├── layout
│ │ └── CommonWrapTemplate.tsx
│ ├── organisms
│ │ ├── footer
│ │ ├── header
│ │ ├── index.ts
│ │ └── sidebar
│ └── uiParts
│ ├── README.md
│ ├── badge
│ ├── button
│ ├── card
│ │ └── BaseCard.tsx
│ ├── container
│ │ └── BaseContainer.tsx
│ ├── drawer
│ │ └── BaseDrawer.tsx
│ ├── error
│ │ └── BaseErrorMessageWrapper.tsx
│ ├── form
│ │ ├── checkbox
│ │ │ ├── BaseCheckBox.tsx
│ │ │ └── LabelAndCheckBox.tsx
│ │ ├── control
│ │ │ ├── BaseFormControl.tsx
│ │ │ └── BaseFormControlLabel.tsx
│ │ ├── helperText
│ │ │ ├── BaseHelperText.tsx
│ │ │ └── IsUseLineHelper.tsx
│ │ ├── label
│ │ │ └── BaseLabel.tsx
│ │ ├── molecules
│ │ ├── required
│ │ │ └── BaseRequired.tsx
│ │ ├── select
│ │ │ ├── BaseSelect.tsx
│ │ │ └── LabelAndSelect.tsx
│ │ ├── switch
│ │ │ ├── BaseSwitch.tsx
│ │ │ └── LabelAndSwitch.tsx
│ │ ├── textarea
│ │ │ ├── BaseTextArea.tsx
│ │ │ └── LabelAndTextArea.tsx
│ │ └── textfield
│ │ ├── BaseTextField.tsx
│ │ └── LabelAndTextField.tsx
│ ├── index.ts
│ ├── item
│ ├── link
│ ├── list
│ ├── loading
│ ├── modal
│ ├── text
│ ├── title
│ └── toast
└── pages
├── common
│ ├── card
│ │ ├── claim
│ │ │ ├── ClaimCardLinkGroup.tsx
│ │ │ └── ClaimCardWrapper.tsx
│ │ └── shopping
│ │ ├── ShoppingCardLinkGroup.tsx
│ │ └── ShoppingCardWrapper.tsx
│ ├── index.ts
│ └── modal
│ ├── claim
│ │ ├── ConfirmDeleteClaimModal.tsx
│ │ └── ConfirmReceiptClaimModal.tsx
│ └── shopping
│ └── ConfirmDeleteShoppingModal.tsx
└── index
└── layout
└── TopPageTemplate.tsx
上記についても一部コンポーネントを省略していますが、問題点についてどのように解決をできたのかを振り返ってみたいと思います。
新しいコンポーネントを作成する中でこれはatoms
なの?それともmolecules
なの?みたいなことを考えることが多くなってしまった
こちらについてはatoms
、molecules
のディレクトリーで作る事をまず分ける事をやめ、どのようなパーツなのかというところで分けるように変更をしました。このように変更をする事によって、対象の部品(button
,card
,modal
)など、どの部品の中にいるのかを探せば良いようになりました。
organisms
についてはパーツを大きくまとめる時に必要かなと思って作っています。
コンポーネントがpageに依存をしているものなのか、どこでも使って良いコンポーネントなのか分からない
まずは下記のようにしてcommon(共通)のものなのか?それともpage(ページ)に依存しているものなのかを分ける事にしました。
├── common
└── pages
その中で特定のページに依存はしているけども、各ページで同じコンポーネントを使用するパターンではpages
配下のcommon
に配置するようにしてみました。
なお、pages
配下にindex
というディレクトリーがあるのですが、こちらは/
のページで使っているコンポーネントになります。
例えば、/shoppings
というページに依存するコンポーネントを作成をする時にはpages
配下にshoppings
というディレクトリーを切るイメージです。
自分的にはこの考え方自体がとてもスッキリしているのですが、もっといい考え方があればご教授ください。
└── pages
├── common
│ ├── card
│ │ ├── claim
│ │ │ ├── ClaimCardLinkGroup.tsx
│ │ │ └── ClaimCardWrapper.tsx
│ │ └── shopping
│ │ ├── ShoppingCardLinkGroup.tsx
│ │ └── ShoppingCardWrapper.tsx
│ ├── index.ts
│ └── modal
│ ├── claim
│ │ ├── ConfirmDeleteClaimModal.tsx
│ │ └── ConfirmReceiptClaimModal.tsx
│ └── shopping
│ └── ConfirmDeleteShoppingModal.tsx
└── index
└── layout
└── TopPageTemplate.tsx
前よりは個人的に管理がしやすくなってよかったなと感じております。
問題④ localStorageを色んな箇所で使用してカオスへ
localStorageはとても便利です。色んなところで呼び出してデータを記憶をさせて、そのデータをいろんなところで呼び出したりと。。
ただページを跨いでlocalStorageを管理する時に何がどのように使用をされているのかが分からなくなってしまうように感じた事、どこでも使えるからこそ制約をつけながら使うのが大事だなと感じました。
過去に色んなところで無闇に使いすぎて少し辛味にハマった記憶こともあったり、、
なのでここでは何がlocalStorageで扱われているのかを管理
するために専用のクラスを作成してみました。
export const noticeStorageKeys = {
// ページ遷移をした後のtoastの表示に使用。ページ遷移前にセット、遷移後に削除
pageMoveNotice: 'pageMoveNotice',
} as const;
export const authStorageKeys = {
// ログインをした後のtokenの管理等に使用。全ページで使用。ログアウト時に削除される。
logined: 'logined',
} as const;
export const storageKeys = {
...authStorageKeys,
...noticeStorageKeys,
} as const;
/* NOTE `TStorageKey` ここにlocalStorageで使うkeyを定義する事。何がlocalStorageで使われているかを管理するため。 */
type TStorageKey = keyof typeof storageKeys;
export type TLocalStorage = {
getStorageItem: (itemKey: TStorageKey) => string | null | undefined;
setStorageItem: (itemKey: TStorageKey, value: any) => void;
removeStorageItem: (itemKey: TStorageKey) => void;
};
class LocalStorage implements TLocalStorage {
private readonly localStorage;
constructor() {
if (process.browser && window.localStorage) {
this.localStorage = window.localStorage;
}
}
public getStorageItem = (itemKey: TStorageKey) => {
if (this.localStorage !== undefined) {
return this.localStorage.getItem(itemKey);
}
};
public setStorageItem = (itemKey: TStorageKey, value: any) => {
if (this.localStorage !== undefined) {
this.localStorage.setItem(itemKey, value);
}
};
public removeStorageItem = (itemKey: TStorageKey) => {
if (this.localStorage !== undefined) {
return this.localStorage.removeItem(itemKey);
}
};
}
export default LocalStorage;
こちらのクラスでやっていることは元々のlocalstorageで扱うメソッドと同様です。ただ今回こちらの専用のファイルを作成する事によって下記のメリットが生まれたと思います。
- localStorageでセットできるkeyの型をTypeScriptで縛る事によってどのようなkeyがセットされているかを把握することができる。
type TStorageKey = keyof typeof storageKeys; // "pageMoveNotice" | "logined"
- localstorageのkeyをこちらのファイルに配置し、コメントを残すなどする事でどのような目的でlocalStorageを使用をしているかがこのファイルを見たら大体わかるようになリました。
export const noticeStorageKeys = {
// ページ遷移をした後のtoastの表示に使用。ページ遷移前にセット、遷移後に削除させること
pageMoveNotice: 'pageMoveNotice',
} as const;
export const authStorageKeys = {
// ログインをした後のtokenの管理等に使用。全ページで使用。ログアウト時に削除させること。
logined: 'logined',
} as const;
実際にローカルストレージを扱うロジック部分までは考慮出来ていないのですが、通常のlocalStorageを呼び出すよりは制約を設けてよくすることができたかなと思っています。
問題⑤ TypeScriptの型が基本的なことしかできずに冗長に書いてしまっていた
前の参画した案件でTypeScriptを扱いましたが正直あまり良い書き方はできていなかったなと思う次第です。。
今でもまだまだ分からないことがありますが、以前より少しだけ扱いがわかるようになったので個人開発で改善をした箇所を少しだけご紹介をしたいと思います。
まず冗長に書くとこうなる。
// 大元になるschemaを定義
export type TShopping = {
id: number;
price: number;
date: Date;
description: string;
isLineNotice: boolean;
isLineNoticed: boolean;
shopId: number;
claimId: number;
createdAt: Date;
updatedAt: Date;
};
// フォームに使用する
export type TShoppingForm = {
price: number;
date: Date;
description: string;
isLineNotice: boolean;
shopId: number;
};
// totalPriceを追加したものを使う
export type TShoppingList = {
id: number;
price: number;
date: Date;
description: string;
isLineNotice: boolean;
isLineNoticed: boolean;
shopId: number;
claimId: number;
createdAt: Date;
updatedAt: Date;
totalPrice: number; // 追加
};
// フォームのエラーメッセージに対応をさせて全てstringにする
export type TShoppingFormError = {
price: string;
date: string;
description: string;
isLineNotice: string;
shopId: string;
};
同じ事を何度も書いています。
最初はまずこんな感じで書いてしまっていました。反省です。
上記をTypeScriptにあるUtilityクラス等を扱うって少しリファクタリングしました。
type TShopping = {
id: number;
price: number;
date: Date;
description: string;
isLineNotice: boolean;
isLineNoticed: boolean;
shopId: number;
claimId: number;
createdAt: Date;
updatedAt: Date;
};
// フォームに使用する
type TShoppingForm = Pick<
TShopping,
'price' | 'date' | 'description' | 'isLineNotice' | 'shopId'
>;
// totalPriceを追加したものを使う
type TShoppingAndTotalPrice = TShopping & { totalPrice: number };
// フォームのエラーメッセージに対応をさせて全てstringにする
type TShoppingFormError = Record<keyof TShoppingForm, string>;
大分スッキリしました。ただスッキリしただけでなく、きちんとTShopping
という大元になるschemaから新しい型が生成されていることが重要だと考えています。
半年前の自分が見た時には、パッと見た時に何しているんですか?となっていると思います。ここから部分的な説明をしますと、
下記についてはフォームで使用をするキーをTShopping
から指定をして新たにTShoppingForm
という型を生成しています。
type TShoppingForm = Pick<
TShopping,
'price' | 'date' | 'description' | 'isLineNotice' | 'shopId'
>;
下記についてはtotalPrice
という型を新たに追加しております。これは直感的に分かりやすいのではないかと思います。
// totalPriceを追加したものを使う
type TShoppingAndTotalPrice = TShopping & { totalPrice: number };
最後にこちらについて
// フォームのエラーメッセージに対応をさせて全てstringにする
type TShoppingFormError = Record<keyof TShoppingForm, string>;
少しだけややこしく見えてしまうかなと思うのですが、やっている事を日本語にすると
TShoppingForm
にあるkeyを取得して、それを全てstring
にしたTShoppingFormError
という新しい型を作る
という事になると思います。実際にエディターでhoverをしながら確認をしてみますと下記のようになります。
TypeScriptで上記のような型を作成をする時は元の型を拡張したりする事によって誤った型を定義した時にtslintでエラーを出して誤った記述をさせないようにすることが大事なんだなと改めて思った次第です。
下記はdescription
をタイポした事によってTSLintでしっかりエラーを出してくれていますね。
このような恩恵を授かる意味でもきちんと元の型
から型を拡張することの重要性が理解できるかと思います。
TypeScriptについてはまだまだ分かっていないこともたくさんあります。。
時間がある時に型職人になれるように勉強した次第です。
最後に
他にも色々と反省点はあるのですが、追加機能やAPI側で実装をすべきことがまだ残っているので、これからまた実装を進めていき、気づきがあればまた記事としてアウトプットできれば良いなと考えております。
こちら少し長くなりましたがお読みいただき、ありがとうございました!