はじめに
私が業務を通してフロントエンドの設計(Vue)を行ったときに、悩み・実践を通して感じたことを書きたいと思います。
PJ-Dで実施した設計
当時(多分2022年ごろ)Vueがある程度わかってきた私はプロジェクトが抱える技術負債に直面していました。コピーコードが大量にあり保守性が損なわれているのがその時の状態でした。
技術レベルとしてはVueのコードを実装することはできましたが、経験年数は浅く、どんな改善アプローチが最適なのか検討しているなかContainer/Presentationalパターンを見つけました。
Container/Presentationalパターンとは
ロジックに関心を持つ「Container」とUIに関心を持つ「Presentational」の2つのカテゴリでコンポーネントを分離するフロントエンドのデザインパターンです。
Containerコンポーネントは、データフェッチ、状態管理、ビジネスロジックを担当し、Presentationalコンポーネントにデータとコールバックを渡します。
一方、Presentationalコンポーネントは、受け取ったデータとコールバックをもとに、UIをレンダリングする役割を持ちます。
この考え方はReactのコア開発者であるDan Abramov(ダン アブラモフ)氏が提唱したデザインパターンであり、React界隈では有名?な設計手法だったみたいです。(React知らないので調べた雰囲気)
※後にPresentational and Container Componentsの記事において自身でこの設計手法を指摘しています。
特に、コンポーネントをこのように分割することはお勧めしません。コードベースでそれが自然である場合は、このパターンが便利です。しかし、私はそれが必要性もなく、ほぼ独断的な熱意をもって施行されるのを何度も見てきました。これが便利だと感じた主な理由は、複雑なステートフル ロジックをコンポーネントの他の側面から分離できるためです。フックを使用すると、任意の分割を行わずに同じことを行うことができます。
Container/Presentationalパターンを実践してみて
ロジックとUIを分離する考え方に当時は「これだ!」と思い食いついたのを覚えています。
ロジックに責任を持つコンポーネントとUIに責任を持つコンポーネントに分け、コンポーネント間はPropsでやり取りする、これだけみるとシンプル設計になりコードの見通しも良くなりそうな気がしていました。
(だって責任を分解するのはフロントエンドに限った話ではなく他でもよく聞く話ですよね?)
実際、このパターンに当てはめてリファクタリングした時は上手く分割できた感触を持っていましたが、後々この方針についていくつか反省点が出てきました。
反省①:共通化
PJ-Dのサービス画面は基本的に同じレイアウトを扱っています。(メインコンテンツエリアに検索条件があり、グラフが2つあり、テーブルが1つある)そのため、画面ごとにレイアウトが変化する訳ではなく、データが変化する、言わばデータ中心の見せ方になります。
ここで、UIに関心を持つPresentationalコンポーネントを各画面で実装した場合、重複したレイアウトを複数作成することに繋がる為避けたいと考えました。
そこで私がとった手段はCommon-Presentational
レイヤーを作成して、そこに頻出するレイアウトコンポーネントを置き、どの画面からも使い回す方法でした。(これも探してそんな考えがあるんだと発見した記憶があります)
サービス初期段階はこれで上手くいったんですが、サービスが成長し画面ごとの個別対応が必要になるにつれてつらみが現れました。
図にも示した通り、Common-Presentational
は大きなレイアウトを持ち、その子コンポーネントとしてPresentational
コンポーネントを配置しています。そのため、Presentational
コンポーネントに対してPropsを渡して特定の変更させたい場合はCommon-Presentational
を経由する必要があり、結果このレイヤーで画面ごとの個別対応が必要になってきました。つまりCommon-Presentational
が肥大化してきて神ってきました。
今思えば、サービスが永遠同じ状態で続くことはまず発生しないことを念頭にいれて、どう共通化するのがベストか練り込みが足りなかったと感じます。
また、Commonを作成するにしてもSlotで枠組みだけ提供するでも良かったのではないかとも思います。
反省②:階層による見通しの悪さ
分離することでスッキリすることに注意を取られていましたが、実際はPropsの流れやイベントのハンドリングが複雑になり、コードの読み易さが損なわれました。
Containerで状態管理しているdataはどのPresentationalに影響があるのか、PresentationalでEmitしたイベントは親側で何をしているのか…など。
Dan Abramov氏が指摘したように、コンポーネントとしてレイヤーを分離するのではなく、ロジックはJSとして(Vueならcomposable)切り出す方向性の良かったと感じます。
そもそもVueのSFCは1コンポーネント内でJS、HTML、CSSが完結するとことが魅力なんじゃないか、その強みを崩して分割する必要なかったのでは…と今は感じます。
PJ-Aではこんな設計にしてみた
前PJでの反省点を踏まえて、新しく始めてPJでは共通化は最小限と見通しのよい構成を意識して設計しました。
ディレクトリ構成は以下のようになっています。
src/
┣ apis/
┣ assets/
┣ components/ # PJ共通のコンポーネント(Atoms相当)
┃ ┣ button/
┃ ┃ ┗ xxxButton.vue
┃ ┣ dialog/
┃ ┣ ┣ composables/
┃ ┣ ┃ ┗ useDialog.ts
┃ ┃ ┗ xxxDialog.vue
┃ ┗ index.ts
┣ layouts/ # Slotでコンポーネントをはめ込む枠組み
┃ ┣ MainLayout.vue
┃ ┗ MainContentHeaderLayout.vue
┣ plugins/
┃ ┗ vuetify.ts
┣ router/
┃ ┗ index.ts
┣ stores/
┃ ┗ xxx.ts
┗ views/ # 画面毎のディレクトリ
┣ AAA/
┃ ┣ component/ # AAAの中で利用する個別コンポーネント
┃ ┃ ┗ xxx.vue
┃ ┗ AAAView.vue # Routing対象のViewコンポーネント
┗ BBB/
┣ component/
┃ ┗ xxx.vue
┃ composable/ # BBB内で利用する共通ロジック
┃ ┗ useXXX.ts
┗ BBBView.vue
ポイント
Common-Presentationalのような中間レイヤーを挟まず、画面単位でレイアウトを構成するようにしています。
views/AAA/
であればAAAView.vue
コンポーネントの中で画面のレイアウトを構成する、views/BBB/
であればBBBView.vue
コンポーネントの中で画面のレイアウトを行うといった思想です。
また、AAAView.vue
の中で機能を開発するとダイアログが必要だったり、他画面にない特殊なUIが必要になるケースがあると思います。そうしたのが出てきたらviews/AAA/component
以下にコンポーネントを作成することで、AAAにしか依存しないことを示します。
そうすることで、コンポーネント間の依存関係が凝縮され開発しやすくなると共に、過度にレイヤー化しないのでイベントハンドリングが理解しやすくなるのではと考えています。
それでもプロジェクト共通で利用したいコンポーネントはでてくると思いますので、その場合はトップレベルのcomponents
ディレクトリにコンポーネントを作成します。作成するときもフラットに作成するのではなく、UI毎にディレクトリを作成して視認性を高めます。
※トップレベルで作成するコンポーネントはボタンやセレクトボックスといった副作用を持たない単純なコンポーネントにしましょう。Atomic DesignのAtomsに相当するイメージで、具体的にはVuetifyが提供しているコンポーネント単位です。
さいごに
プロダクトはこれからリリースを迎えるため、この設計が正しかったのか知るのはまだ先のことになると思います。
大事なのは、どんな設計手法をとっても適宜プロダクトに応じた形へ適用させることなので、プロダクトと共に成長させて行きたいと思います。