Webフロントエンドのコツ・基本設計指針について語ってみる
Last Updated on 2024年5月15日 by lemonade
概要
自分はあまりフロントエンドに精通しているタイプではありませんが、今までVue, React, Angular, Svelte等を触ってきてわかった初心者が通りやすい基本的なフロントエンドの設計についてまとめてみようと思います。想定しているアプリケーションは8画面程度より多い中規模程度のものです。小規模のアプリケーションに関しては、何も考えずに作った方が早いですし、50〜100画面を超えるような大規模なアプリケーションの場合は、レイヤーを分けるだけでなく縦に分割する必要が出てくると思います。あくまで自分の考えです。
- 概要
- スタイル編
- UIコンポーネント編
- スクリプト編
- コンポーネントレイヤーを3つに分ける
- レスポンシブデザインでは、スマートフォン用とPC用のコンポーネントを分け、CSSとJavaScript併用で切り替える
- 変更の可能性があるものに状態管理ライブラリを使用する
- クラス指向ではなくデータ指向
- 値は不変にする
- Server APIはWrapするかOpenAPI Generatorを用いて型をつける
- propsでは不変なオブジェクトで受け渡す
- バケツリレーは我慢
- Server APIから受け取ったものは状態管理ライブラリへ流し、状態の流れを一方通行にする
- 値を渡す側の変数命名は、propsの命名に合わせない。
- クラスの付け替えを行い、スタイルはJavaScriptで極力いじらない
- エラーハンドリングはthrowし、Tsdocに記述する
- UI崩れを防ぐVRTとユーザーの操作をテストするE2Eは分ける
- まとめ
スタイル編
リセットCSSを使用する
リセットCSSは、SafariやChrome, Firefox等でそれぞれ異なるスタイルの初期設定を一律にするCSSのことです。私は全ての初期スタイルを削除する以下のリセットCSSが好みです。このスタイルをグローバルimportしておくことでデバイス間の差異をある程度気にしなくても良くなります。逆に、これ以外にはグローバルimportするCSSは、要素に当たっているスタイルの要因を追いにくくなるためおすすめしません。私が全ての初期スタイルを削除するリセットCSSを好んでいるのも、当たっているCSSは全て明示的に指定したものにすることで、どこで設定されたかが追いやすくなるためです。
https://github.com/elad2412/the-new-css-reset/blob/main/css/reset.css
色の変数を設定する
これはデザインの際にも言えることですが、色が少ないということはシンプルだと感じられる要素の1つです。そのため、基本的に使用する色を定義しておくことをおすすめします。
具体的には、以下のものをデザインタイプごとに作ると良いでしょう。(黒字のエリア用の変数や白地のエリア用の変数など、スタイルの違う場所ごとに変数を作っておきます)全ての変数を埋める必要はありません。使用するものだけで大丈夫です。
base color | 基本背景色 |
on base color | 背景色の上の基本文字色 |
on base light color | 背景色上の薄い文字色(無効時など) |
on base heavy color | 背景色上の濃い文字色(強調時など) |
surface color | 背景色に少し浮く色(白背景なら薄いグレーなど) |
primary color | 基本文字色以外の色 |
accessible primary color | 背景色等にprimary colorがうまく載らない際にprimary colorを濃くしたり薄くしたりして合いやすくしたもの |
secondary color | primary colorではないアクセント色 |
accessible secondary color | 背景色等にsecondary colorがうまく載らない際にprimary colorを濃くしたり薄くしたりして合いやすくしたもの |
failure color | エラー・失敗時の色 |
success color | 成功時の色 |
上記それぞれの色に on 〇〇 color
, on 〇〇 light color
, on 〇〇 heavy color
があります。デザインの際からFigmaで同じ名前で色の変数定義を行っておくのがおすすめです。
文字列のコンポーネントを作成する
文字列もCSS変数やFigmaの変数として定義した方が良いですが、コンポーネントにしておくことがおすすめです。コンポーネントにした上でCSS変数にするのは、input要素のplaceholderなどではテキストコンポーネントが使用できないためCSSで要素を当てる必要があるためです。おすすめは以下の種別のテキストコンポーネントを作成することです。propsで種別を変更するかコンポーネント単位で分けるかはどちらでも良いと思いますが、個人的にはJavaScriptで処理させないコンポーネントから分ける方が好きです。
body font | 標準サイズ。大体14〜18px |
footnote font | 注釈用など、小さいフォント。10〜14px |
large title font | 最も大きいフォント。デザインによってはこれ以上大きいフォントを作ってもいい |
title1 font | 大きいフォント |
title2 font | 中程度大きいフォント |
title3 font | 少し大きいフォント |
以上のフォントのそれぞれのstrongタイプ(強調)も作ります。アクセシビリティ上、本当にstrongタグにするかスタイルだけ強調にするかは別にした方が良いかもしれません。もちろん、デザインによってこれよりフォントの数が少なくても多くなっても構いません。フォントは凝れば凝るほどデザインが良く見えるようになります。
UIライブラリは無理やりCSSを変更しない
Material UIやIonicなど色々なUIライブラリがあります。しかし、どれをそのまま使っても大体どこかで見たようなデザインになってしまい、サイト特有のオリジナリティが出せないという悩みや、スタイリングに時間がかけられないなどの理由から既存のUIライブラリを使用し、そのUIライブラリをグローバルCSSでカスタマイズしてオリジナリティを出すことがあります。これがダメな理由としては、CSSが追いづらくなることもありますが、一番の問題はUIライブラリのバージョンアップでクラス名が変わった時にUIが崩れてしまうことです。UIライブラリは提供されたカスタムできるAPIだけでオリジナリティを出すか、使わずに自分で実装することを検討しましょう。最近ではHeadlessUIという、スタイリングは自分で行えるもののアクセシビリティ等を考慮しやすいものもあります。
UIライブラリはWrapする
これは通常のライブラリでも言えることですが、長期的な運用を見込んでいる場合はUIライブラリをWrapした自作コンポーネントを作ることがおすすめです。UIライブラリのアップデートによりインターフェイスが変わったとしても修正箇所が少なく済みますし、UIが崩れた場合に最悪他のライブラリや自分で実装したものに置き換えるのが楽になります。他にも、UIライブラリに対するカスタマイズをサービス内で統一しやすくなったり、TypeScriptで型が付与されていないUIライブラリでもラップすることで自分で型をつけてあげたりすることができます。
widthとheightはコンポーネントごとに決め打ちするか親コンポーネントから指定する
widthとheightは基本的にコンポーネント内で決め打ちすることをお勧めします。コンポーネントは使用する側からどこで使用しても崩れない安定した振る舞いを保つべきだと考えているためです。そのため、リセットCSSなどで要素のデフォルトスタイルを box-sizing: border-box
にすることを推奨します。これは、要素のサイズによって広がって欲しい場合に明示的に指定されるようになることで、コンポーネントが使用側の状況で変化することがよりわかりやすくなるためです。また、隣接するコンポーネントや親コンポーネントのwidthやheightの値を極力使用しないようにしてください。コンポーネント内で完成・閉じていないとどこからその値が出てきたのか追えなくなったり、コンポーネントの使用側の状態でUIが崩れてしまいます。
コンポーネント外に影響するmarginを書かない
marginは配置を調節するのに非常に便利ですが、UIコンポーネント外に影響するmarginを持ってしまうと親コンポーネントから配置を調節することが非常に難しくなってしまいます。コンポーネント内で影響は閉じたものにするため、marginのエリアをUIコンポーネント外に出さないようにしてください。
UIコンポーネント編
UIコンポーネントの最も外側の要素にはUIコンポーネント名のclass名をつける
これはフレームワークにもよりますが、変更時・デバッグ時にはブラウザのインスペクタから影響する要素がどのコンポーネントであるかをわかりやすくする必要があります。その際にクラス名にUIコンポーネント名を付与しておくことでインスペクタで特定した要素がどのコンポーネントであるかを素早く確認することができます。
ドキュメントを書く
コードの関数にはJsdocやTsdocを記述することが多いと思いますが、UIコンポーネントにもコード内にドキュメントを記述していますか? ブラウザのインスペクタからUIコンポーネントを辿ることはできても、その逆は難しいことはありませんか? UIコンポーネントは再利用性の高いAPIであることが多いです。その際にUIコンポーネント自体の説明や使用方法、注意点などを書いていないと再利用・変更する際にUIコンポーネントを読み解かなければなりません。関数だけでなく、UIコンポーネント自体に対するコード内ドキュメントを書くことをお勧めします。
スクリプト編
コンポーネントレイヤーを3つに分ける
ヘキサゴナルアーキテクチャなどのように、Webフロントエンドもレイヤーで境界線を分けることによって扱いやすくします。
- 共通UIコンポーネント層
- 機能単位UIコンポーネント層
- ページ単位UIコンポーネント層
基本的にディレクトリもこの3つで分けるのがおすすめです。
共通UIコンポーネント層では、ボタンやテキストフィールド、モーダルなどドメインに関わらないものです。理想的には機能単位UIコンポーネント層やページ単位コンポーネント層にはHTMLタグがほとんどなく、共通UIコンポーネント層のコンポーネントだけで構成されるのが良いと思います。この層では状態を明示的に持たないようにしてください。テキスト入力欄であっても状態自体は上位コンポーネントに任せるだけにしてください。
機能単位UIコンポーネント層では、例えばユーザプロフィール変更フォームや通知一覧リストなど特定のドメインと直結したコンポーネントです。このレイヤーでは短時間であれば状態を保っても構いません。例えばモーダルを開いている間の入力情報を保持しておいたり、フォームの内容を保持しておいたりなどです。ただし、長期間保持する必要があるのであればページ単位UIコンポーネント層、またはページ単位UIコンポーネント層経由で状態管理ライブラリに状態を持たせるようにしてください。
ページ単位UIコンポーネント層はユーザーが見えている画面に対応するコンポーネントです。この層では主にスマートフォン用画面とPC用画面を切り替えたり、その画面の最初に閲覧した際にだけロードする値を保持すること、状態管理ライブラリから状態を受け取ることだけになります。基本的に大まかな配置以外のスタイリングは行いません。
レスポンシブデザインでは、スマートフォン用とPC用のコンポーネントを分け、CSSとJavaScript併用で切り替える
ブログ記事などの静的サイトを作成する場合はCSSでメディアクエリによってレスポンシブデザインをするのがおすすめですが、Webアプリケーションなどの動的サイトでレスポンシブデザインをするにはCSSでのメディアクエリが過剰に複雑になってしまいます。
おすすめは、ページ単位UIコンポーネントと機能単位UIコンポーネントの中間でスマートフォン用とPC用の配置のコンポーネントの2つを作り、ページ単位UIコンポーネントで両方を表示することです。このとき、JavaScriptでデバイス情報を読み取ってコンポーネントを切り替えるとSSR等をした際に一瞬スマートフォン用(またはPC用)の画面が写ってしまったり、レンダリングが一瞬遅れてしまうことがあります。そのため、JavaScriptではマウント時にどちらかのコンポーネントを削除し、CSSのメディアクエリで表示する方を決めると一瞬のデバイス差が気になりにくくなります。
変更の可能性があるものに状態管理ライブラリを使用する
フロントエンドに限らずですが、状態というものが存在すると非常にプログラミングは複雑になります。Web APIサーバーで状態の複雑さが気になりづらいのはDatabaseという優れたソフトウェアに状態の複雑さを閉じ込めているからです。フロントエンドでも状態管理ライブラリに状態の管理を任せることで複雑さを軽減することができます。この際に全ての状態を状態管理ライブラリに保存してしまうと無意味に状態にアクセスできる範囲を広げてしまいます。そのページを表示する際に1度だけ読み込むものに関しては状態管理ライブラリではなくページ単位管理UIコンポーネントに状態を持たせるようにしましょう。
クラス指向ではなくデータ指向
オブジェクト指向パラダイムに親しみのある方は、フロントエンドでも値に対してメソッドを持たせるためにクラスを作成することが多いと思います。しかしこれはあまり推奨できません。なぜならばUIフレームワークやUIライブラリと自作インスタンスは非常に相性が悪いためです。TypeScript自体がクラスと相性がさほど良くないというのもありますが、UIライブラリを使用する際に毎回インスタンスとの変換を行うのは大変かつ利益が少ないです。フロントエンドで扱うロジックは値とあまり結びついていないことも多いです。
私は、オブジェクト指向ではなくデータ指向を推奨します(オブジェクト指向とデータ指向が相反するわけではありません)。ここでいうデータ指向は書籍データ指向プログラミングのことを指しています。データに対するロジックを関数で記述し、それに対してフロントでも単体テストを記述します。
値は不変にする
基本的にフロントエンドでは親コンポーネントから回ってくるオブジェクトを受け取って表示するだけです。そのためデータを弄くり回す必要はほとんど発生しません。自然と値は不変になるはずです。readonlyにするかは自由ですが、不変にするという意思は持ってください。私は常に全ての値をreadonlyで定義します。
Server APIはWrapするかOpenAPI Generatorを用いて型をつける
Server APIサーバーへのアクセスは、どこからでも発生する可能性があります。そのためServer APIに破壊的変更が入ると影響範囲が大きくなりがちです。Server APIへは直接 fetch
を行うのではなく、何かしらの関数やclientでwrapすることをお勧めします。astahmer/openapi-zod-clientというOpenAPIスキーマからクライアントを生成するライブラリがあります。基本的なJSONでの受け渡しのみを行うのでしたらこちらを基本的に使用するのがお勧めです。ただし、ファイルなどの複雑な形式での通信や、SvelteKit特有のfetch(一応fetch拡張もあります)など使用できない場面もあります。
propsでは不変なオブジェクトで受け渡す
コンポーネント間ではバケツリレーが多く発生すると思います。その際に複数のpropsを一緒に受け渡しているとただただバケツリレーのコストが大きくなります。propsではプリミティブな型だけでなく不変なオブジェクトも受け渡しをすることができます。(この際にmutableなオブジェクトを渡すとバグりやすいです)機能単位UIコンポーネント層以上ではドメイン知識に沿ったオブジェクトの型を作成して一気に受け渡しを行い、共通UIコンポーネント層ではプリミティブ型で受け渡しを行うようにすることをお勧めします。
バケツリレーは我慢
階層が増えてくるとpropsのバケツリレーが増えてきて状態管理ライブラリから直接取って来たいなどということも増えてくると思います。しかし、それをしてしまうと特定機能UIコンポーネント層の再利用が難しくなってしまいます。また、渡すデータの加工ロジックが重複してしまうなどということがあるかもしれませんがそれはUIコンポーネントから切り離して考えるべきです。最近ではSvelteやVue3.3などではバケツリレーを簡単にする仕組みが出来てきています。受け渡しをオブジェクトにまとめるなどで軽減できない場合は、おとなしくバケツリレーを受け入れてください。
Server APIから受け取ったものは状態管理ライブラリへ流し、状態の流れを一方通行にする
基本的にページ単位UIコンポーネント以外では副作用を持たないことが重要です。副作用を持つと再利用性が失われ、子コンポーネントによる影響を考慮しなければならなくなり、親コンポーネントから扱いにくくなります。そのため、外部APIからの副作用は状態管理ライブラリを経由して上から流すことにします。これによって状態は状態管理ライブラリから上の階層から下の階層へと流れていくことになり下の階層からの副作用を無くすことができます。
値を渡す側の変数命名は、propsの命名に合わせない。
on:click
というイベントのハンドラー関数名を onClick()
という名前にしたことはありませんか? on:click="onClick"
では、コードを読んだ際に結局クリック時に何が行われるのかが実装を見るまでわかりません。on:click="openModal"
であればクリック時に何が実行されるのかが一目瞭然ですよね? ハンドラや変数の命名を横着してpropsの名前にしないようにしてください。例外としてバケツリレーで親から受け取った値を子コンポーネントに流す場合はそのままで構いません。
クラスの付け替えを行い、スタイルはJavaScriptで極力いじらない
スタイルをJavaScript側で変更する方法は各フレームワークに存在すると思います。しかし、大部分のスタイルをJavaScriptで定義するようにしてしまうと少しUIを修正したいだけなのにJavaScriptのロジックを読まなくてはいけないということが起こってしまいます。基本的にJavaScriptではクラスの付け替え等を担当し、CSSによってUI定義を行うようにしましょう。例外としては棒グラフなどのように数値で長さを決めてあげないといけない場合などにはそこの部分だけJavaScriptで操作するようにしてください。
エラーハンドリングはthrowし、Tsdocに記述する
エラーハンドリングの方式としては大きく3つ考えられます。Result型(Either型)のようなものをライブラリやユニオン型で定義して伝播させる方法、try catchを使用する方法、発生箇所で対処する方法があると思います。私の考えとしてはフロントエンドでは外部API由来のエラーで対処できないものが半分くらいを占めており、それらは防ぐことよりも起きたことをスタックトレースで確認できることの方が重要だと思っています。そのため、Result型のようにスタックトレースを取りにくいものは扱わずthrowを行うことを優先します。また、回復可能なエラーに関してもスタックトレースを取得できるようにエラーを投げて、その旨をTsdocのthrowとして記述して伝播することをお勧めします。
UI崩れを防ぐVRTとユーザーの操作をテストするE2Eは分ける
PlaywrightやCypressなどといったメジャーなE2EテストフレームワークにはVRTというスクリーンショットを取得しておいて比較するテストなども含まれていることが多いです。しかし、VRTとE2Eを同時に行うことはお勧めできません。なぜならばVRTは実装後にしか作成できないからです。テストは基本的に実装する前に記述すべきであると考えているためE2Eテストを先に記述し、実装後に退行を防ぐためのVRTを作成すべきだと考えています。また、VRTとE2Eでは求められているものが異なり、VRTではありとあらゆる画面のスナップショットを小さい粒度で撮っておくべきですが、E2Eではユーザーの一連のストーリーをテストすることが優先されます。そのため、この2つでは役割が違うため、同じテストについでにVRTもするということはお勧めしません。
まとめ
自分流のフロントエンドの基本設計指針として、以下のポイントを押さえることで、より安定しメンテナンスしやすいコードベースを構築することができると考えています。
- リセットCSSを使用する: デバイス間のスタイル差異を減らすため、リセットCSSをグローバルに適用しましょう。
- 色の変数を設定する: 使用する色を定義し、デザインをシンプルに保ちましょう。
- 文字列のコンポーネントを作成する: テキストスタイルをコンポーネント化し、一貫性を持たせます。
- UIライブラリのカスタマイズに注意する: グローバルCSSでの変更を避け、提供されたカスタムAPIを使用しましょう。
- コンポーネントのレイヤーを分ける: 共通UIコンポーネント、機能単位UIコンポーネント、ページ単位UIコンポーネントの3層に分け、責任を明確にします。
- レスポンシブデザインの工夫: スマートフォン用とPC用のコンポーネントを分け、CSSとJavaScriptを併用して切り替えます。
- 状態管理ライブラリの活用: 変更の可能性がある状態を集中管理し、複雑さを軽減します。
- データ指向の設計: クラスではなく関数やデータ構造を用いて、ロジックを管理しましょう。
- 値は不変にする: データの一貫性を保つため、基本的に全ての値を不変にします。
- Server APIの管理: APIアクセスを関数やクライアントでラップし、型安全性を確保します。
- バケツリレーを我慢: propsの受け渡しを適切に行い、状態管理ライブラリの過剰な使用を避けます。
- 副作用の管理: 外部APIからの副作用は状態管理ライブラリを通じて上から下に流し、一方向に管理します。
- クラス命名の一貫性: コンポーネント間でのデータ受け渡しやイベントハンドラーの命名を明確にします。
- スタイルはCSSで管理: JavaScriptでのスタイル操作を最小限にし、クラスの付け替えで対応します。
- エラーハンドリングの記述: エラーはthrowし、Tsdocでドキュメント化します。
- VRTとE2Eの分離: UI崩れ防止のためのVRTとユーザー操作をテストするE2Eは分けて行います。