Vue 2で書かれたプロジェクトをReactで一から書き換えた話
こんにちは。助太刀開発部にてフロントエンドを担当している木下です。
今回は、弊社が提供しているサービスの1つである助太刀社員という求人プラットフォームにて、企業側が利用する管理画面のフロントエンドをVue 2(Nuxt)からReact(Next)へと一から書き直したことについて、所感などをまとめていきたいと思います。
フルリライトに至った経緯
私がプロジェクトに参画した時には、すでに大方の開発が完了している状態ではあったものの、その時点のコードはリファクタリングではどうにもならないくらいの設計上の問題が多々ありました。
認証方法も異なる、求職者側が利用する画面と同じリポジトリで開発されていた
グローバルステートが全部1ファイルにぶちこまれている(当然求職者側のものも含む)
コンポーネントが1ミリも分割されていない(ページコンポーネントに大量のdivとpとspanと全てのロジック)
CSSが全部グローバル定義
TypeScriptで書かれていると思ったら、JavaScriptだった(型注釈がほぼ書かれていない、書かれていると思ったらany)
ESLintすら入っていなかった(これは即入れた)
etc.
ちまちまとリファクタリングを重ねていたものの、結局これ全部リファクタリングするのって、書き換えるのと何が違うんだろう?と思い、フルリライトを決行しました。ちょっと先に少し大きめの機能追加が控えており、このままだと確実に死への後進が始まりそうだったのも一つの理由です。
Vue 3ではなくReactを新たに導入した経緯
さて、フルリライトは決定しましたが、改めて技術選定を行い、UIライブラリとしてReactを導入することにしました。助太刀のフロントエンドは全てVueで組まれており、Reactの採用は初となります。その理由について話したいと思います。
Vue 3追従の遅れ
今年(2022年)2月7日にVue 3がVueのデフォルトとなりました。Vue 2のサポートも2023年末までとなります。そんな中で一から書き換えるのにVue 2という選択肢はないわけですが、例えばNuxt 3がunstableだったり、一部依存ライブラリがVue 3サポートを放棄していたり、対応が遅れていることもあり、今このタイミングで置き換えるということを考えると少し不安が残りました。
その上で他の選択肢を検討とすることとなりますが、Reactの提供する洗練されたAPIや、2つの優秀なReact向けオープンソースライブラリがユーザー体験、開発者体験を共に高めてくれ、求めるゴールにもマッチしていることがReactを使ってみようとなった主な理由です。
React Hooksの魅力
React Hooksの有意な点が、ロジックを見た目部分から分離でき、再利用可能な単位に分割できることは周知の事実です。煩雑になりやすいイベント処理や副作用などもカプセル化できることにより、コンポーネントの肥大化を抑えることができ、メンテナンス性が格段に向上します。
もちろん、Vue 3のComposition APIでも同様の事が実現できます。ただ肌感ではReactの方が洗練されている印象を受けました。ライフサイクルの違いが当然あるので比較するのはナンセンスかもしれませんが、.valueやcomputedが冗長な感じが否めなかったり、ref、reactiveが諸説あったり等、少々複雑な印象です。
Reactがコアとして用意しているAPIは非常にシンプルなものです。しかしこのシンプルさこそが強みであり、シンプルであるからこそ陳腐化しにくく、堅牢性の高い状態を維持できるのかなと考えています。
あと(これは個人的に思うことですが)、シンプルであるがゆえにコードを書くときにどうすれば綺麗になるかを意識しやすく、綺麗に書くことを追い求めると自然と良い設計になるのではないかなとも考えています。(もちろん、ここら辺は熟練度によって左右される部分ではあります)。シンプルは正義です。
優秀なライブラリの存在
Reactのエコシステムにおいて、特に移行へのモチベーションとなった2つのライブラリを紹介します。
Chakra UI
既存コードはBootstrapVueを使っていましたが、そもそもデザインはBootstrapベースではないため、デフォルトのスタイルを打ち消すためのCSSが大量に散らばっていました。今回はそれを避けたく、ユーティリティファーストなものを選択しようと考えました。
Vueだとtailwindcss + Headless UI等が現時点では有力な候補になりますが、Headless UIが現時点では少し物足りず、作るとなるとちょっとめんどくさいコンポーネントをゴリゴリ作らないといけません。時間の限られる中、コンポーネントの細かい機能の実装でハマるよりも、本質的な開発に集中できる方が良いです。
そんな中で、コミュニティが活発、メンテナンスも良くされている、開発体験も良さそう、テーマも細かく調整できそう、実装したいUIのほとんどを実現できるChakra UIが目につきました。
特にテーマ機能について、助太刀のフロントエンドシステムはある程度見た目が統一されています。そのためテーマを1つ作っておけば、新たにプロジェクトではこのテーマファイルを元に調整するだけでスムーズな立ち上がりができると考え、大きなモチベーションとなりました。
React Query (TanStack Query)
既存システムの問題点として、APIのレスポンスが場合によっては遅くなることがあり7秒くらいローディング画面になってしまうものがありました。もちろんAPI自体を改善すればいいという話ではありますが、どこかに遷移して戻るたびに長〜いローディング画面というのは使い心地が悪すぎます。
これを改善するためにはキャッシュ機構が必要ですが、時間も限られておりそれを一から実装するのは現実的ではありません。
そこら辺をうまく取りまとめてくれそうだったのがこのライブラリでした。これを噛ませることによって、1回目のデータ取得以降はキャッシュが存在するため、2回目の遷移ではローディング画面を挟まずに瞬時に表示され、裏でひっそり更新されるようになります。これによりかなりUXの向上が見込まれるため、Reactに変更する大きなモチベーションとなりました。
Vueにも似たようなライブラリがあるにはありますが、使用率やメンテナンス頻度、コミュニティの活発さの面でやはり劣ります。将来を見据えて運用する上では、この辺はかなり気になるポイントです。
※ただ、今後TanStack QueryのVue用アダプタが開発予定につき、この評価は変わる可能性があります。
Reactを導入してみて
現在フロントエンド担当は私一人ですが、私自身はReactに触れたことがあるのもあり、実装自体は割とスムーズに進み1ヶ月ほどで完了しました。
ざっと実装を終えて、個人的に感じた点をちょっとだけ書こうと思います。
良かったと感じた点
改めて、Reactの関数コンポーネントの書きごこちは良いものだなと感じました。コンポーネントやフックともに特殊なディレクティブ等は必要とせず、全て素のJavaScriptで完結するので、流れるように記述ができます。集中力も途切れません。当然TypeScriptとの相性問題で困ることもありませんでした。
React Queryは、事前の想定通りうまく機能してくれました。SWRの仕組みが導入できたおかげで、既存システムよりも遥かにキビキビした動作を少ないコードで実現することができ、製品価値をより一層高められることができたかなと考えています。
また、キャッシュのinvalidate機能がかなり良い感じです。特に上位コンポーネントや兄弟コンポーネントで使用されているデータの再取得をかけたい場合、(Vueの場合だと)emit地獄になったりrefを貼って子関数を呼びださざるを得なくなったりと見通しが悪くなりがちでしたが、これらが完全に解決されました。CSS in JSについて正直どうなのと思っていた部分もあったのですが、めちゃくちゃ良いです。型補完も効くし、クラス名決めに時間をかける必要もありません。スタイルの上書きも容易です。ある程度テーマの方にスタイリングの定義を書いておいたおかげで見通しが悪くなくなることもなく、CSSから完全に解放されました。
微妙だと感じた点
Chakra UIのテーマコンフィグのドキュメントが乏しく、ソースコードやissueを検索しまくる次第に・・・まあでもこれはそんなにいじるファイルでもないので(流用もできますし)、新しい要素が出てきた時に根気良くやれば良いかなと思います。
設計についての小話
このまま終わると内容的に薄い気もするので・・・本筋とは少しずれますが、フルリライトに当たって検討した設計についてちょっとだけピックアップして書いてみようと思います。
ディレクトリ構成をレイヤーベースではなく機能ベースに
よくあるディレクトリ構成としては、レイヤーごとにフォルダを切る下記のようなものだと思います。
api/
├─ job-postings/
│ ├─ getJobPostings.ts
├─ conversations/
│ ├─ getConversations.ts
components/
├─ ui/
│ ├─ List.tsx
│ ├─ ListItem.tsx
│ ├─ Button.tsx
├─ job-postings/
│ ├─ JobPostingList.tsx
│ ├─ JobPostingListItem.tsx
├─ conversations/
│ ├─ ConversationList.tsx
│ ├─ ConversationListItem.tsx
hooks/
├─ common/
│ ├─ useCookie.tsx
├─ job-postings/
│ ├─ useGetJobPostingsQuery.ts
...
それを、各機能ごとにフォルダを切る方針としました。ルートレベルのものには共通で利用するのみ入れます。
features/
├─ job-postings/
│ ├─ api/
│ │ ├─ getJobPostings.ts
│ ├─ components/
│ │ ├─ JobPostingList.tsx
│ │ ├─ JobPostingListItem.tsx
│ ├─ hooks/
│ │ ├─ useGetJobPostingsQuery.ts
├─ conversations/
│ ├─ api/
│ │ ├─ getConversations.ts
│ ├─ components/
│ │ ├─ ConversationList.tsx
│ │ ├─ ConversationListItem.tsx
│ ├─ hooks/
│ │ ├─ useGetConversationsQuery.ts
components/
├─ ui/
│ ├─ List.tsx
│ ├─ ListItem.tsx
│ ├─ Button.tsx
hooks/
├─ useCookie.tsx
...
これは他のプロジェクトでの実際の話ですが、プロダクトがスケールしたとき、レイヤーベースだと各ファイルの配置が大きくバラけてしまい、ファイルを探すことの苦労(特に新規参入時)、凝集度の低下、機能が削除されたときにコードが残ってしまうなどの問題がありました。
機能ベースでフォルダを切れば、これらの問題にアプローチできるかなと考えました。
助太刀社員の企業向け管理画面には大きく4つの機能がありますが、各機能の関係性なども考慮して、最終的な機能フォルダの数は10個となりました。
なぜ見た目の通り4つに分けなかったかというと、「機能」の粒度をしっかり検討しなければ、機能同士の依存度が高くなり、余計メンテナンス性が下がる危険性もあるためです。
今回に関しては機能を跨いだ依存も最小限に抑えることができ、結果的に見通しもよくなったので、このアプローチは正解だったなと考えています。
APIレスポンスのユースケースに応じたデータ変換
既存のコードは、APIから返却されたオブジェクトを直接利用しており、APIの改修による影響範囲調査が大変だったり、同じような値変換ロジックが量産されDRYの原則に反するようなことがありました。他にも、APIのレスポンスを直接渡すということはそのコンポーネントが使わないプロパティも渡されることになり、本当にこのプロパティが使用されているのかわからなかったり、コンポーネントの再利用時に不便になることがあります。
React QueryにはuseQueryフックのselectオプションを用いると、任意のデータに変換することができます。これを利用して、ユースケースごとにデータ変換関数を噛ませたuseQueryを返却するカスタムフックを作成して、それぞれのコンポーネントは素のデータを返却するuseQueryを用いないようにしました。
// (注意)実際のコードではありません・一部省略
// --- src/features/job-postings/api/getJobPostingById.ts ---
/**
* API呼び出し関数
*/
function getJobPostingById(id: number): Promise<GetJobPostingByIdResponse> {
return httpClient.get(`/api/job-postings/${id}`);
}
/**
* 素のデータを返却するuseQuery
*/
export function useGetJobPostingByIdQuery<TData = GetJobPostingByIdResponse>(
id: number,
config?: UseQueryOptions<GetJobPostingByIdResponse, HttpClientError, TData>,
) {
return useQuery<GetJobPostingByIdResponse, HttpClientError, TData>({
...config,
queryKey: jobPostingQueryKey.single(id),
queryFn: () => getJobPostingById(id),
});
}
// --- src/features/job-postings/hooks/useGetJobPostingListItemQuery ---
/**
* コンポーネントで用いるデータ型
*/
export type JobPosting = {
id: number;
previewUrl: string;
title: string;
plan: string;
period: string;
status: string;
canEdit: boolean;
likeCount: number;
applicantCount: number;
pageViewCount: number;
};
/**
* リスト用にデータを変換するuseQuery (コンポーネントではこれを用いる)
*/
export function useGetJobPostingListItemQuery() {
return useGetJobPostingByIdQuery<JobPosting>({
select: useCallback((resp) => ({
id: resp.id,
previewUrl: getPreviewUrl(resp.id),
title: resp.title,
period: `${formatDate(resp.start_publish_at)} ~ ${formatDate(resp.end_publish_at)}`,
canEdit: isEditableStatus(job.status.value),
status: job.status.name,
planName: job.plan.name,
likeCount: job.likes,
applicantCount: job.applicants.length,
viewCount: job.pv,
}), []),
});
}
これにより、APIに変更があったとしても、このフックだけを修正すれば良いようになります。このインターフェースがいろいろな場所で使用されていたとしても修正は1箇所ですみます。変更漏れでエラーみたいなしょうもないバグ案件がなくなります。
まだプロダクトが成長段階なこともあり、結構な頻度でパラメータがdeprecatedになったり、レスポンスの形も変わることがあるので、この部分は明確にルールを定めてプロダクトの成長に追従できる設計を意識しました。
フォームの整頓
今回はformライブラリとしてreact-hook-formを選択しました。ただ、どのライブラリを選択するにしても、うまく設計しないとごちゃごちゃしがちなのがフォームです。APIからとってきた既存の値を流し込む、送信後の処理、フォーム内部のロジックなど、気づくとコンポーネントの凝集度が低下し複雑でメンテナンスしづらいものになってしまいます。
それらを防止するために、今回は全てのフォームにおいて「データ取得」「ロジック」「プレゼンテーション」の一律3段構成としてみました。
データ取得層
一番上の層では、フォーム初期データの取得を行います。編集できないステータスのものを編集しようとした等のハンドリングも、ここで行います。
取得したデータは、次のロジック層に渡します。
// (注意)実際のコードではありません・一部省略
export const JobPostingEditContainer = () => {
const id = useRouteParamId();
const { data, isError } = useGetJobPostingDefaultFormInputQuery(id);
if (isError) {
return <ErrorScreen />;
}
if (!data) {
return <LoadingScreen />;
}
if (!data.canEdit) {
return <JobPostingCannotEditErrorScreen />;
}
return <JobPostingEditForm defaultValues={data} />;
};
ロジック層
中間の層では、useForm呼び出しやデータ送信に関するロジックなどを記述します。このように分けると、フォームの一部でしか使わない細かいプレゼンテーションロジックなどによって見通しが悪くなることを防ぐことができます。他にも、フォームの要素部分が同じで「更新」と「作成」でAPIの送信先を変えないといけないパターンにおいて有意になると考えています。
ちなみに、useFormContextを使用せずにメソッドやcontrolオブジェクトをpropとして直接渡しているのは、フォームの状態変更でコンポーネントが再描画されるパフォーマンスの問題を防ぐためです。フォーム要素が多いと顕著になります。
// (注意)実際のコードではありません・一部省略
export const JobPostingEditForm = ({ defaultValues }: JobPostingEditFormProps) => {
const { control, register, handleSubmit } = useJobPostingEditForm({
defaultValues,
});
const { mutate, isMutating } = useJobPostingEditFormMutation();
return (
<form onSubmit={handleSubmit(mutate)}>
<JobPostingEditFormContent
control={control}
register={register}
/>
</form>
);
プレゼンテーション層
一番下の層では、フォームパーツを並べるだけのコンポーネントです。ただ見た目のみに集中することで、デザインの変更によりロジックに影響を与えることはありません。
細かいロジックはさらに下位コンポーネントに逃すことで、再利用性と可読性を保つようにしています。
例えば、EditFieldコンポーネントは渡されたcontrolオブジェクトをもとに、エラーがあればそれを表示します。
// (注意)実際のコードではありません・一部省略
export const JobPostingEditFormContent = ({ register, control }: JobPostingEditFormContentProps) => {
return (
<>
<EditField control={control} name="title">
<Input
placeholder="例:施工管理大募集!"
{...register('title')}
/>
</EditField>
<EditField control={control} name="description">
<Textarea
rows={4}
placeholder="例:未経験でもOK!懇切丁寧に教えます!アットホームな職場です!"
{...register('description')}
/>
</EditField>
</>
);
}
export const EditField = <T extends FieldValues>({
name,
control,
children,
}: EditFieldProps<T>) => {
const { errors } = useFormState({ control });
return (
<FormControl isInvalid={!!errors[name]}>
<Stack>
<Box>{children}</Box>
<FormErrorMessage>
{errors[name]?.message}
</FormErrorMessage>
</Stack>
</FormControl>
);
}
この方法が1番!とまでは考えませんが、少なくとも今回の改修においては汚くなりがちなフォームコンポーネントを比較的綺麗に保つことができたかなと考えています。
総括
まだプロダクトが小さめだからこそ、このフルリライトは完遂できたと思います。大きくなってからだと本当にきつかったと思います…
Vueだけだった助太刀のフロントエンドスタックにReactが加わったことで、Reactやってみたい、Reactが好きなエンジニアの人達も助太刀に興味を持ってくれるんじゃないかなと期待しています。なんやかんやでReact人気ですしね!
一緒に助太刀のWebアプリケーションを盛り上げていきませんか!?💪💪💪