Another works CTOの塩原です。
フロントエンドの設計は複業クラウドでもいろんなことを試しました。
今回は、現在の複業クラウドのフロントエンド設計を紹介できればと思います。
技術スタック
- Next.js
- React
- Typescript
フロントエンド設計の考え方
1. 無理な共通化・抽象化を行わない
これは過去の経験からですが、一時期フロントエンドの設計に対して、DDDの考え方や、クリーンアーキテクチャの考え方を取り入れようとしたことがありました。
バックエンドではDDDやクリーンアーキテクチャを導入しているのでフロントエンドでもその考え方を取り入れてみようという考えでした。
複業クラウドのコアな概念をドメインモデルとして定義したのですが、フロントエンドの場合
表示する画面によってデータ構造が影響を受けてしまい、
例えば複業クラウドでも「求人」という概念が一覧画面で見た時と、詳細ページで見た時、閲覧履歴や検索結果から見る時で表示される情報が変わるので共通化や抽象化に苦戦しました。
画面も頻繁に書き変わることが多かったので開発コストと見合わなくなりました。
2. 変更における影響範囲が広くならないことを意識する
また上記に関連することですが、共通化などをしすぎると画面変更に伴い共通化したもの自体にかなり大きな変更が入り、結果として特定の箇所の画面変更にも関わらず他の画面にも影響が出てしまうといったことも発生しました。
特に複業クラウドでは大幅に画面仕様が変わることも多く、そういったフェーズでは特に抽象化したものがそもそも0から書き直さなければならないといったことが発生しました。
3. 仕組み化に頼りすぎずコミュニケーションを重要視する
エンジニアには自動化や仕組み化といったことが得意かつ好きな人が多く、ついつい全てを仕組み化してしまいたくなりますが、プロダクトチームで人数がまだ10人に満たない中、常に変化も大きいため、仕組み化や自動化は取り入れながらもコミュニケーションを取ることも重要視するようにしています。
推奨利用しているライブラリ
- styled-component
- reactでcssを書くためのライブラリ
- jsx, tsxファイル内で書くことができる
- css in js
- 値に応じて動的にCSSを変更するようなコードを書ける
- https://styled-components.com/
- SWR
- stale-while-revalidateをうまくやってくれる
- キャッシュから値を返したあと、裏側で再フェッチし最新データに置き換える
- 利用する側がフェッチ処理などを意識することなく、データのように利用することができる
- https://swr.vercel.app/
- stale-while-revalidateをうまくやってくれる
- react-hook-form
- フォーム管理ライブラリ
- バリデーションの機能も持っている
- Yupを使わずに書くことができる
- 以前はformikを使っていたが、内部でrefをうまく活用することで再レンダリングする箇所を最小限に抑えていたりなどパフォーマンス面に優れている
- https://react-hook-form.com/
ディレクトリー構成
ディレクトリーの説明
-- src
|-- adapter
|-- fcCoreApi
|-- localstorage
|-- assets
|-- exception
|-- hooks
|-- features
|-- projectSearch
|-- mypage
|-- lib
|-- date
|-- pages
|-- styles
|-- ui
|-- homeland
|-- origin
adapter
データ通信が発生する部分などの技術的な部分を扱うレイヤーを入れています
API通信についてという項目で詳細に説明しますが、useSWRを使うのはこの層になります
features
機能単位で分解しているモジュールです
こちらもfeatureディレクトリーについてという項目で詳細に説明します
lib
日付の共通処理や数字の変換処理など普遍的なクラスをこの中に入れています
utilではなくlibとした理由は、複業クラウド以外のプロジェクトで利用されても問題ないものが入るという意味を表すためにlibという名前を使いました
カスタムhook系の処理はhooksディレクトリーに入れるのでこの中には入りません
hooks
カスタムhooksの中でfeaturesを跨いで使うような共通処理を置くモジュールです
ファイル名は必ずuseXXXとなります
Featureディレクトリーについて
弊社ではfeatureというディレクトリーを使って単数もしくは複数のページをまとめています。
まとめた中に共通となるコンポーネントや、カスタムhooks、型定義や定数などを格納しています。
|-- features
|-- projectSearch
|-- ui
|-- pages
|-- origin
|-- consts
|-- projectConsts // 求人の型を入れておく
|-- hooks
そうすることで、hooksやconstsなどの利用範囲が限定されているので
実際に表示で使う型に合わせて定義することができます。
featureディレクトリーというと、一ページ内での機能単位でディレクトリーを切る場合もありますが、機能単位に微妙に分解しづらいものなども存在するのとページ単位の場合、Next.jsでは pages/
ディレクトリーをいじらなければ移行自体はやりやすいのでページ単位を採用しました。
ESLint
feature同士が依存するとせっかくディレクトリーを分けた意味がなくなるので、
その依存を防ぐためにeslintを使ってimportの依存関係を制御しています。
https://github.com/import-js/eslint-plugin-import/blob/main/docs/rules/no-restricted-paths.md
module.exports = {
extends: [
'next',
'next/core-web-vitals',
'plugin:import/recommended',
'plugin:import/typescript',
'prettier',
],
settings: {
'import/resolver': {
typescript: {},
},
},
rules: {
'import/no-restricted-paths': [
'error',
{
zones: [
{
from: './src/features/!(accountDelete)/**/*', // 対象の依存パス
target: './src/features/accountDelete', // 禁止するパス(正規表現可)
message: 'features同士で依存することはできません', // エラーメッセージ
},
],
},
],
},
};
コンポーネント設計
landscape/homeland/origin
複業クラウドでは三種類のコンポーネントを使いわけています。
以前はAtomicデザインを適用していたのですが、実際の運用を行う上で分けやすい分け方がいいと思いコンポーネントの種類を見直しました。
landscape
ボタンや、formなどの基本的なコンポーネント
複業クラウドでは企業側とタレント側などでフロントエンドリポジトリーが分かれているので
npmライブラリで実装して複数箇所で使えるようにしています。
homeland
このコンポーネントは上記のコンポーネントほど頻出ではないものの、
求人カードやタレントプロフィール画面などプロダクトのコアになりそうなデザインコンポーネントを定義しています。
そうすることで、変更が入った時にも一箇所を修正すれば変更が反映され、デザインする際にも一貫性が保たれます。
こちらはリポジトリーに直接定義するので、企業側・タレント側それぞれで定義されています。
landscape | homeland | origin | |
---|---|---|---|
デザイナーとのコミュニケーション | 必要 | 必要 | 不要 |
ビジネスロジックを持てるかどうか | NG | OK | OK |
実装方法 | npmライブラリ | src/ui/homeland配下に実装 | src/ui/originもしくは各features配下に定義 |
API通信について
SWRを使ったAPI通信
弊社ではAPI通信にswrを利用しています。
swrにはいくつか利点がありますが、特に気に入っているポイントとして
- キャッシュを簡単に行なってくれる
- useEffectとuseStateの乱立を防げる
- 楽観的更新・定期ポーリング・エラー時の再試行などを簡単に実装できる
という点が挙げられます。
特にuseEffectとuseStateの乱立を防げるという点では下記のコードで比較をしてみます
useSwrを使わない場合
import useSWR from 'swr'
function Profile() {
const [data, setData] = useState(undefined)
useEffect(() => {
const callback = async () => {
const result = await fetcher()
setData(result)
}
callback()
}, [setData])
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
useSwrを使う場合
import useSWR from 'swr'
function Profile() {
const { data } = useSWR('/api/user', fetcher)
if (!data) return <div>loading...</div>
return <div>hello {data.name}!</div>
}
とこのようにSWRを使わない場合、useEffectとuseStateを駆使しなければいけません。
APIが一つなのでまだシンプルですが複数必要になるとかなり複雑になってきます。
SWRを使った設計
上記のサンプルコードはSWRの公式ドキュメントを参考にしたものなので、
コンポーネント内に直接 useSWR
を記載していますが、
このような書き方にはいくつか問題があると考えています。
- 異なるデータのキャッシュの値が被ってしまう可能性がある
- APIのパスにすれば被っても問題ない
- postやgetのrequest情報もパスに含める想定なら
- ただ理想としてはキャッシュ値は中央集権的に管理しておきたい
- postやgetのrequest情報もパスに含める想定なら
- APIのパスにすれば被っても問題ない
- 第二引数のデータ構造をAPIのまま使うのではなく、加工したいときにコンポーネントに加工処理が漏れる
- APIのレスポンスやリクエスト内容などはコンポーネントの関心ごとからは切り離したい
- これはプロダクトの思想によっては異なる
そのため複業クラウドでは以下のような三層構造で設計をしています
Adapter層では、APIと直接通信し、APIのエンドポイントごとに通信するための関数を用意し、その中でSWRを実行しています。
feature内のhooksで、adapter層の関数を使って、見た目に必要な情報などを加工する処理などを記載します。
楽観的更新やポーリングなどもこのcustom hooksで実装するようにしています。
componentはAdapterを直接参照せずに、feature内のhooksから参照するようにすることで、componentがそこまでAPIの情報を意識することなく利用することができます。
adapter/useGetProfile.ts
export function useGetProfile(request: {id: string}) {
const { data, error } = useSWR(['/api/user', request], fetcher)
return data, error
}
feature/hooks/useProfile.ts
export function useProfile(params: {id: string}) {
const {data, error} = useGetProfile(params)
return {
data: {
...data,
fullname: data.lastName + data.firstName
},
loading: !error && !data
}
}
feature/ui/origin/profileBox.tsx
function ProfileBox() {
const { data, loading } = useProfile()
if (loading) return <div>loading...</div>
return <div>hello {data.fullname}!</div>
}
まとめ
フロントエンドに限らず設計は日々最適解を見つけながら進化させていくべきだと考えています。
なので今回の設計で満足せずに、会社の規模・チームの規模、技術の進歩に合わせて常に改善を続けていきたいと考えています。
Share this post