Lab

Astroの静的サイトにログインなしのお気に入り機能を付ける――localStorageで保存する設計

Astro製の静的サイトで、ログインやデータベースを追加せずにお気に入り機能を実装した構成を整理します。localStorageには記事IDだけを保存し、表示用データはビルド時に用意する設計です。

  • Astro
  • JavaScript
  • localStorage
  • フロントエンド

3分英語物語には、気になった物語を保存しておける「お気に入り」機能があります。

この機能のために、ログイン画面やユーザーアカウント、データベースは用意していません。

保存先に使っているのはブラウザの localStorage です。

ただし、物語のタイトルや説明文まで丸ごと保存しているわけではありません。localStorage に入れるのは物語のIDだけにして、表示に必要な情報はAstroのビルド時データから取り出しています。

この記事では、静的サイトの構成を保ったままお気に入り機能を追加したときの設計を整理します。

静的サイトでも、端末内に状態は保存できる

Astroで静的HTMLを生成しているサイトでも、ブラウザ上のJavaScriptは使えます。

そのため、すべての機能にサーバー側の保存先が必要なわけではありません。

3分英語物語のお気に入りで必要だったのは、次のような状態です。

  • どの物語をお気に入りにしたか
  • 詳細ページを開いたとき、その物語がお気に入り済みか
  • お気に入りページで、保存した物語だけを一覧表示できるか

この程度であれば、端末内に保存できる localStorage だけでも実現できます。

一方、複数端末での同期やアカウント単位の保存が必要なら、この方式では足りません。

最初に「必要な機能が端末内だけで完結してよいか」を決めると、サーバーを追加する必要があるか判断しやすくなります。

localStorageには記事IDだけを保存する

お気に入りボタンでは、次のような配列を保存しています。

[
  "a-short-story",
  "another-story"
]

実際の保存処理を簡略化すると次の形です。

const STORAGE_KEY = 'favoriteStories';

const saveFavorites = (favorites) => {
  localStorage.setItem(
    STORAGE_KEY,
    JSON.stringify(favorites),
  );
};

ここへタイトル、説明文、難易度、公開日などをまとめて保存することもできます。

ただ、記事側の情報は後から変更される可能性があります。

たとえば説明文を修正したとき、localStorage に古い説明文まで保存していると、保存済みデータとの整合を取る必要が出てきます。

そこで3分英語物語では、ブラウザ側には**「どの記事を選んだか」だけを残す**ようにしています。

記事情報そのものは、常にサイト側のデータを基準にします。

読み込み時は壊れたデータも想定する

localStorage から取り出せる値は文字列です。

JSONとして保存していても、何らかの理由で想定外の値になっている可能性はあります。

現在の実装では、読み込みを try...catch で囲んでいます。

const getFavorites = () => {
  try {
    return JSON.parse(
      localStorage.getItem(STORAGE_KEY) || '[]',
    );
  } catch {
    return [];
  }
};

お気に入り機能はサイトの主要コンテンツを読むために必須ではありません。

そのため、保存データを読めなかったときにページ全体をエラーにするより、空のお気に入りとして扱う方が自然です。

専用ページ側ではさらに、配列かどうかと、各IDが文字列かどうかも確認しています。

const favorites = getFavorites();

const favoriteIds = new Set(
  Array.isArray(favorites)
    ? favorites.filter((id) => typeof id === 'string')
    : [],
);

ブラウザ保存の値を「自分のコードが書いたものだから必ず正しい」と決めつけず、使う直前に最低限の形を確認しています。

お気に入りボタンは追加と解除を同じ処理にする

詳細ページには、各物語のIDを持ったボタンを置いています。

<button
  type="button"
  data-favorite-button
  data-story-id={storyId}
>
  ☆ お気に入りに追加
</button>

クリック時には、保存済みIDの中に現在の記事があるかを確認します。

const favorites = getFavorites();
const isFavorite = favorites.includes(storyId);

const nextFavorites = isFavorite
  ? favorites.filter((id) => id !== storyId)
  : [...favorites, storyId];

saveFavorites(nextFavorites);

お気に入りでなければ追加し、すでに入っていれば削除します。

保存処理とは別に、ボタンの表示も現在の状態に合わせます。

button.textContent = isFavorite
  ? '★ お気に入り済み'
  : '☆ お気に入りに追加';

これなら、「追加ボタン」と「解除ボタン」を別々に用意する必要はありません。

お気に入り一覧には、全記事の表示用データをビルド時に渡す

少し工夫が必要だったのは、お気に入り専用ページです。

localStorage に入っているのはIDだけなので、そのままではタイトルや説明文を一覧表示できません。

そこでAstro側では、ビルド時にContent Collectionから記事情報を読み込みます。

---
const stories = await getCollection('stories');

const favoriteStories = stories.map((story) => ({
  id: story.id,
  number: story.data.number,
  title: story.data.title,
  description: story.data.description,
  estimatedTime: story.data.estimatedTime,
}));
---

このデータをブラウザ側のスクリプトへ渡します。

<script define:vars={{ favoriteStories }}>
  // ブラウザ側の処理
</script>

ブラウザ側では、保存済みIDと一致する記事だけを抽出します。

const savedStories = favoriteStories.filter((story) =>
  favoriteIds.has(story.id),
);

この構成なら、役割を次のように分けられます。

  • localStorage:ユーザーが選んだ記事IDを保持する
  • Astro:現在の記事データをビルド時に用意する
  • ブラウザ:両者を照合して表示する

保存データと記事データを分離しているので、記事タイトルや説明文を更新しても、お気に入り情報を保存し直す必要はありません。

localStorageだけを見てHTMLを作る必要はない

お気に入りページを完全にクライアント側だけで作ろうとすると、記事情報の取得方法を別に考える必要があります。

JSON APIを用意する方法もありますが、このサイトではそのためだけにAPIを増やしていません。

静的サイトでは、公開される記事情報はビルド時点ですでに分かっています。

そのため、変化するのは「どの記事を選んだか」だけと考え、選択状態だけをブラウザへ持たせています。

サーバーを持たない構成では、何でもクライアント側へ寄せるのではなく、ビルド時に確定できるものとブラウザで変化するものを分けると実装を単純にできます。

お気に入りページは検索結果向けのページではない

お気に入りページは、同じURLでも端末ごとに表示内容が変わります。

また、何も保存していない利用者には空の状態が表示されます。

3分英語物語では、このページに noindex を設定しています。

<Seo
  title="お気に入り|3分英語物語"
  description="この端末に保存したお気に入りの英語物語を確認できます。"
  noindex={true}
/>

公開記事の一覧とは役割が違い、検索エンジンへ内容を届けるページではなく、利用者本人のためのUIだからです。

サイト内に存在するページをすべて検索結果へ出す必要はありません。

機能ページとコンテンツページを分けて考えると、どのページをインデックス対象にするかも整理しやすくなります。

localStorage方式には明確な制約がある

実装が軽い代わりに、この方式には制約があります。

一番大きいのは、お気に入りがサーバーへ保存されないことです。

そのため、別の端末や別のブラウザを使えば同じお気に入りは引き継がれません。ブラウザの保存データを削除すれば、お気に入りも消えます。

また、localStorage はパスワードや認証情報のような機密情報を置くための仕組みではありません。

今回保存しているのは公開済みの記事IDだけです。

この制約を受け入れられない機能なら、アカウントやサーバー側保存を検討する必要があります。

逆に、端末内だけで完結してよい補助機能なら、ログイン機能を追加しないこと自体が構成を単純にする選択になります。

機能の大きさに合わせて保存場所を選ぶ

3分英語物語のお気に入り機能では、次の構成にしています。

  1. 詳細ページで記事IDだけを localStorage に追加・削除する
  2. 読み込み時はJSONの破損や想定外の形式を考慮する
  3. 記事タイトルなどの表示情報は localStorage に保存しない
  4. お気に入りページではAstroが全記事の表示用データをビルド時に用意する
  5. ブラウザ側で保存済みIDと記事データを照合する
  6. 個人向けの機能ページなので検索結果には出さない

ログインやデータベースを導入すれば、複数端末同期などできることは増えます。

ただ、必要なのが「この端末で後から読みたい記事を覚えておく」だけなら、そこまで大きな仕組みにしなくても実現できます。

サーバーが必要かどうかを機能名で決めるのではなく、保存した状態をどこまで共有・永続化する必要があるかで決めると、静的サイトのまま実装できる範囲を見つけやすくなります。