Astroコンポーネントを作る
はじめに
まえがき
HTML/CSSで作成していたポートフォリオサイトを、Astroを使用して作り直すことにした。その際の作業記録や躓いた箇所のメモを書いていく。
目標
- HTML/CSSでコーディング済みのものをAstro仕様に作り変える。
- 他のプロジェクトでも使い回せる汎用的なAstroコンポーネントを作る。
開発環境
下記の記事で制作した環境をベースに作っていく。
全体像
手順
1.コンポーネントを作成
2.レイアウトを構築
htmlファイルを一つずつ上から見ていき漏れがないようにする。
コンポーネントとレイアウトの区分
- レイアウト:セクション要素やdivを用いてデザインを組み立てる
- コンポーネント:パーツを成している各要素、あるいはその集合
コンポーネント
分類
現場のプロから学ぶ CSSコーディングバイブルのCSS設計を参考に整理する。
- elements:最小単位のパーツコンポーネント
- components:複数要素で構成されるパーツコンポーネント
- layouts:レイアウト用Astroコンポーネント
レイアウトも広義ではコンポーネントなので厳密には3段階。
小単位のコンポーネントはより大きい単位のコンポーネントを構成するパーツになり得る。
elements→components→layoutsの順に作成していくことで共通部分のコンポーネント化を図る。
作成指針
- HTMLの構成が同じ場合は、異なるCSSを割り当てるにしても一つのコンポーネントにまとめる
- AstroPropsを介し子コンポーネントからクラスを受け取れるようにする→特定のクラス名を渡すことで適用スタイルを切り替えるため
- h要素は一つのコンポーネントとして作り、使用時に見出しレベルを決定する
- 汎用性を意識する
- map()メソッドを使用した動的なコンテンツ生成を積極的に取り入れ、使用時の記述量を削減する→コンテンツテキストは極力JSONデータに切り出しする
- ビルド後に極力JSが入らないようにする→サイトの軽量化を目指すため。HTML/CSSのみで実現できる機能は極力JSを使用しない
作成予定コンポーネント
- ロゴ
- 見出し
- ナビゲーション
- ヒーロー画像/テキスト
- プロフィールカード
- ボタン
- スキルカード
- カード(画像+リンク)
- 画像カルーセル
- テーブル
- フォーム
CSS/SCSS設計
AstroでCSS設計は必要か
Astroコンポーネントでは、CSSはHTMLと一緒に各コンポーネント内に記述していく。
CSSの記述はコンポーネントごとにスコープ化される。(ビルド時はCSSに各コンポーネント特有のハッシュを付与することで出力後もスコープ性を保つ)(個人的にはハッシュに任意の値を指定できるようになってほしい。ビルド後のCSSを見通しよくしたい)
そのためクラス名によるCSS設計を厳密に行わなくても、意図しない場所に意図しないスタイルが適用されるリスクは減っている。
しかし、コンポーネント内での記述にCSS設計のルールを定めることでどのコンポーネントも統一的な記述になるというメリットもある。考え方を忘れないためにも続けていくことにする
どのように行っていくか
現場のプロから学ぶ CSSコーディングバイブルのCSS設計を参考に。BEMをアレンジ。
- 接頭辞-コンポーネント名_パーツ名
- 接頭辞-コンポーネント名-派生名
のようにクラス名を付与していく。
アレンジ部分
elementsの中でも更に汎用性が高い
- img
- text
- box
等はグローバルSCSSに組み込んだ。
→textとboxはutilityに当たる。この2つは勝手が違うので更にファイルを分けるべきか?検討。
コンテンツデータをどこで取得するか
まだ自分なりの答えは出していない。
各ページを作成するために使用するAstroファイル
1つのページを作る際に複数個のAstroファイルを使用する。
各ページ、コンポーネントでデータを取得するのが候補になるかと思う。
- 各ページ:ページの種類に応じたレイアウトを読み込む
- レイアウト:レイアウトの構成要素である他のレイアウトやコンポーネントを読み込む
- コンポーネント:元となるパーツ。パーツのボリューム次第では更に粒度が小さいコンポーネントを読み込む可能性がある
コンポーネントの使い回しで考える
同一プロジェクト内で複数使い回すコンポーネントを使用するページ
各ページでコンテンツを取得する。
複数プロジェクトで使い回すが同一プロジェクト内では1回しか使わないコンポーネントを使用するページ
複雑な作りのページの場合、各ページで取得すると任意の値や要素を流し込むのが大変になる。
JSONからデータを読み込み動的にコンテンツを生成する場合
各ページでデータを取得
コンポーネント自体はシンプル。
各ページがデータの取得を必要とするコンポーネントを複数持つ場合、1つのファイルで複数のJSONファイルを扱い煩雑になるデメリットがある。
コンポーネントでデータを取得
使用場面ごとにコンポーネントを切り分け、データの流し込みをコンポーネント側で行う。
1つのコンポーネントファイルを使い回せるところを、わざわざ複数個作成しなければならないというデメリットがある。
再利用性/記述の分散のどちらを取るか
再利用性を取る
各ページでデータを取得するのが適している。
記述の分散
使用場面ごとにコンポーネントを切り分け、データの流し込みをコンポーネント側で行うのが適している。
サイトの規模
小さい規模のサイト
使用場面ごとにコンポーネントを切り分けても、コンポーネントファイルの数が膨大になりすぎる恐れは少ない。
大きい規模のサイト
コンポーネントを増やしすぎるとコンポーネントファイルの整理が必要となる、各ページに対応するコンポーネントを探しづらくなる恐れがある。
各ページのコンテンツ量
多い場合
各ページで取得すると、複数のJSONファイルのインポートの読み込みやmap()メソッドの記述、props変数名が必要となり記述が膨大化する恐れがある。
コンポーネントで取得することで記述を分散可能。
少ない場合
各ページで取得する負担がない。
JSONファイルを複数ページ/同一ページの複数コンポーネントで使用する場合
複数ページで使用する
コンポーネントで取得することで、JSONファイルの読み込みを一度で済ませることが可能。
同一ページの複数コンポーネントで使用する
各ページで取得することで、JSONファイルの読み込みを一度で済ませることが可能。
map()メソッドで使用する引数名
基本
元となるデータ名の頭文字を小文字にし引数とする。
---
import Data from '@data/data.json';
---
<div>
{
Data.map((data) =>(
<div>{data.name}</div>
))}
</div>
JSONファイルのプロパティ名を指定しそこから生成する場合
プロパティ名+Itemを引数にする。
---
import Data from '@data/data.json';
---
<div>
{
Data.map((data) =>(
<div>{data.name}</div>
<ul>
{data.list.map((listItem) => (
<li>{listItem}</li>
))}
</ul>
))}
</div>
Formコンポーネント
<form>要素は項目内容によっては比較的多くの要素で構成されることになる。
可能な限りシンプルな記述にしていきたい。
方針
<input>や<textarea>等、各パーツをそれぞれコンポーネント化。
Formコンポーネントを親コンポートとし、そこに使用する子コンポーネントを読み込み配置していく。
ディレクトリ構造
conponentsフォルダ内にformフォルダを作成。
Formコンポートを含めて全てのフォーム関係コンポートをそこで管理する。
/
└─ src
└─ components
└─ components
└─ form
├─ Form.astro
├─ Input.astro
├─ Textarea.astro
├─ Check.astro
├─ Radio.astro
└─ Submit.astro
フォーム読み込み時
各ページにフォームを設置する場合はFormコンポーネントを読み込む。
具体的な作成内容
Formコンポーネント
各パーツを配置していない初期の状態。
---
---
<form id="entryForm" action="" class="c-form" method="post">
<table class="c-form_inner">
<caption class="c-form_caption"> 大項目名 </caption>
<tbody>
<!-- ここに各パーツコンポーネントを配置していく -->
</tbody>
</table>
</form>
各パーツコンポーネント
---
export interface Props {
name: string;
required?: boolean;
}
// 子コンポーネントから受け取るpropsを定義
const {
name: nameName,
required = false,
...rest
} = Astro.props;
---
<tr>
<th class="c-form_item">
<label for={nameName} class={required ? 'is-required' : ''}><slot /></label>
</th>
<td class="c-form_input">
<!-- <input>や<textarea>等メインパーツを記述 -->
<!-- フォーム全体で共通規格の注釈 -->
<slot name="attention" />
</td>
</tr>
<style lang="scss"></style>
注釈がある場合のパーツコンポーネント要素の記述
<Input type="" gormId="" name="" autocomplete="">
項目名
<small class="c-form_attention" slot="attention">フォームの注釈事項</small>
</Input>
フォーム部品、チェックボックスラジオボタンの作成方針
コンポーネントの分け方
input要素だが、HTMLの構造が異なるためそれぞれ独立してコンポーネントを作成する。
使用する値
JSONに入力し、親コンポーネントで読み込む。
プロパティの項目名は共通するため子コンポーネント側で一括で型定義可能。
JSONデータが増えすぎるのを予防するために
チェックボックス、ラジオボタン、それぞれにつき一つずつのデータにする。
同一フォーム内で複数使用する場合、JSON内のオブジェクト一つにつき各ボックス、ボタンとする。子コンポーネントにデータを渡す場合は「JSONデータ.オブジェクト名」として渡す。
nameプロパティは都度記述する
name属性の値は同一フォーム内で全て統一される。
JSONデータを作成するときに
{ "check1": {
"name": "共通の名前",
"item1": { "value": "選択肢1", "formId": "id1" },
"item2": { "value": "選択肢2", "formId": "id2" } } }
とすることもできる。nameを一度の記述で済ませることができる。
しかし、map関数を使用する際name分もコンテンツが生成され、これを除外するとastroの方の記述が複雑になってしまう。(if文でnameプロパティを除外、更に式になるのでreturnを返すなど)
テキストコンポーネント
作成方針
- HTML要素とコンテンツの記述を切り分けて両方の見通し、メンテナンス性を向上させたい
- コンテンツテキストをJSONに記述する
- 作成予定のポートフォリオサイトを参考にし必要な機能をつける
機能の洗い出し
- 一般テキスト:p要素
- リスト:ul要素+li要素
- リンクテキスト:a要素
- 必要に応じてスタイルを変えるため、任意にクラスを付与する
想定粒度
1つのセクション要素のコンテンツ部分を担う。
h要素とセットでの使用を想定。h要素の記述内容とテキストコンポーネントの内容が一致する粒度にすることで、「このテキストコンポーネントはどんな中身だったか?」と見失わないようにする。
ただし、間に他のコンポーネントパーツが入る場合は複数個の使用も想定。
<section>
<Heading label={2 as 1 | 2 | 3 | 4 | 5 | 6}>見出しテキスト</Heading>
<TextBox data={Data.text1 as TextBoxItem[]} />
</section>
{
"text1": [
{ "type": "paragraph", "content": "標準のテキストスタイル。ここに本文を入力。" },
{
"type": "paragraph",
"class": "is-text-strong",
"content": "強調テキスト。ここに本文を入力。"
},
{
"type": "link",
"url": "https://",
"content": "is-list-link。リンクテキスト"
},
{
"type": "list",
"class": "is-list-dot",
"listItem": ["行頭がドットのリスト", "項目", "項目"]
}],
"text2":[]
動的生成の記述
if文を使用し"type"の値ごとに出力要素を切り替える。
<div class="e-textBox">
{
data.map((item) => {
// type:paragraphの場合は<div>を生成
if (item.type === 'paragraph') {
// "class"に値を持つときのみclass属性を生成
return <p class:list={[item.class]}>{item.content}</p>;
// type:listの場合は<ul><li></li></ul>を生成
// リストの性質上、配列であることを必須要件とする
} else if (item.type === 'list' && Array.isArray(item.listItem)) {
return (
<ul class:list={item.class}>
{item.listItem.map((listItem) => (
<li>{listItem}</li>
))}
</ul>
);
// type:linkの場合は<a>を生成
} else if (item.type === 'link') {
return (
<a href={item.url} class:list={['is-text-link', item.class]}>
{item.content}
</a>
);}})}
</div>
完成したコンポーネントを見て
「これでコンテンツテキストの見通しは良いのか?このJSONを書くのも結構手間では?」
という疑問が出てくる。
作成予定のポートフォリオサイトでは、テキスト(装飾含む)・リンク・リストが混在する場面も多い。それらを一つのコンポートで包括できるのは便利ではある。
しかし、シンプルな長文テキストのみの場面もある。その時にこのコンポーネントは面倒かつ多機能すぎるのではないか。
そういうわけで、1テキストボックス1スタイルに絞った、テキスト生成のみのコンポーネントを別途作ることにした。
テキストコンポーネント~シンプルバージョン
作成方針
- JSONデータの記述をシンプルにすることを優先する。
- p要素生成以外の機能は持たせない
- 各要素ごとにスタイルを使い分ける必要もない
切り捨てた機能が欲しい場合は先のテキストコンポーネントを使用する。
結論
{
"text1": [
"コンテンツテキストを一つの配列に収めたタイプのテキストボックス。",
"一つの段落を\"\"内に記述し、,区切りで配列内に収めていく。",
"TextBoxコンポーネントと比較すると、リンクやリストを挟むことは出来ない、テキストボックス単位でテキスト書式が統一される、というデメリットはある。",
"一方で、JSONの記述が簡単に早く行える。シンプルに複数段落構成のテキストを記述する場面ではこちらを採用すると良い。",
"改行をしたい場合は○\n\\nを入力。(このテキストは○の部分で\\nを記述している)"
],
"text2": []
}
<div class="e-textBox">
{data.map((item) => <p class:list={[className]}>{item}</p>)}
</div>
<style lang="scss">
.e-textBox p {
white-space: pre-wrap;
}
</style>
ボックス
役割的にはレイアウトではあるが、コンテンツ内に複数種類が複数回出てくるような粒度が小さいものを想定。
- importの記述が膨大になる恐れがある
- HTMLの記述はセクション要素もしくはdiv要素の単一になるためコードの見通しは変わらない
このことから、当面はグローバルSCSSで記述する。