Lab

Astroで記事詳細から「元いた一覧」へ戻す――fromパラメータでページ位置を引き継ぐ

一覧の2ページ目やカテゴリ別一覧から記事詳細を開いたあと、元いた一覧へ戻れるようにする実装を整理します。Astroでfromパラメータを渡し、許可した一覧URLだけを戻り先として採用し、前後ナビにも引き継ぐ方法です。

  • Astro
  • JavaScript
  • UI
  • ルーティング

3分英語物語には、通常の物語一覧だけでなく、ページネーション、古い順、カテゴリ別、レベル別の一覧があります。

一覧から記事を開いたあと、詳細ページにある「物語一覧へ戻る」を常に /stories/ へ向けるだけなら実装は簡単です。

ただ、それでは2ページ目から開いた記事でも、カテゴリから開いた記事でも、戻ると一覧の先頭へ移動してしまいます。

そこで現在は、どの一覧から記事を開いたかを from パラメータで詳細ページへ渡し、戻り先として引き継ぐ構成にしています。

この記事では、一覧側で戻り先を渡すところから、詳細ページでの検証、前後の記事へ移動したときの引き継ぎまでを整理します。

固定の「一覧へ戻る」では元の文脈が消える

たとえば、物語一覧の3ページ目を見ているとします。

/stories/page/3/

そこから記事を開き、詳細ページの「物語一覧へ戻る」が常に次のURLだった場合、

/stories/

3ページ目ではなく一覧の最初へ戻ります。

カテゴリや並び順がある場合は、さらに文脈が失われます。

/categories/daily-life/oldest/page/2/

この一覧から記事を開いたのに /stories/ へ戻ると、

  • カテゴリ
  • 古い順という並び方
  • 2ページ目という位置

がすべて消えます。

記事詳細の内容そのものには影響しませんが、一覧を続けて読んでいるときには小さな不便が積み重なります。

一覧側で「戻ってきてほしいURL」を渡す

3分英語物語では、記事カードを表示する StoryCard.astro に listReturnPath を渡しています。

通常の一覧なら、

const listReturnPath = '/stories/';

2ページ目以降なら、現在のページ番号を含めます。

const listReturnPath = `/stories/page/${currentPage}/`;

カテゴリ別一覧では、カテゴリ、並び順、ページ番号から戻り先を組み立てています。

const basePath =
  order === 'desc'
    ? `/categories/${categorySlug}`
    : `/categories/${categorySlug}/oldest`;

const listReturnPath =
  currentPage === 1
    ? `${basePath}/`
    : `${basePath}/page/${currentPage}/`;

この listReturnPath を記事URLのクエリパラメータへ入れます。

const storyHref = listReturnPath
  ? `/stories/${story.id}/?from=${encodeURIComponent(listReturnPath)}`
  : `/stories/${story.id}/`;

URLは次のような形になります。

/stories/example-story/?from=%2Fstories%2Fpage%2F3%2F

from の中身を encodeURIComponent() しておけば、戻り先のパスをクエリパラメータの値として安全に区切れます。

history.back()ではなくURLに戻り先を持たせる

単に前のページへ戻すだけなら、JavaScriptの history.back() も使えます。

ただ、今回は「戻るボタン」ではなく、詳細ページ上に明示的な「物語一覧へ戻る」というリンクを置きたいので、ブラウザ履歴だけに依存しない形にしています。

history.back() は実際の閲覧履歴を戻るため、

  • 記事URLを直接開いた
  • 新しいタブで記事を開いた
  • 外部サイトから記事へ来た

といった場合に、戻り先が物語一覧になるとは限りません。

一方、from をURLに持たせておけば、そのリンクがどこへ戻るためのものなのかをページ自身が判断できます。

ブラウザの「戻る」は履歴を戻す機能としてそのまま使い、サイト内の「一覧へ戻る」は一覧へ戻す機能として分けています。

詳細ページではURLSearchParamsでfromを読む

詳細ページ側では、現在のURLから from を取得します。

const params = new URLSearchParams(window.location.search);
const from = params.get('from');

ただし、取得した値をそのままリンク先にはしません。

クエリパラメータはURLを書き換えれば誰でも変更できるため、想定している一覧URLかどうかを確認してから使います。

fromをそのまま信用せず、許可する一覧URLを限定する

現在の実装では、戻り先として許可するパスを正規表現で限定しています。

考え方を簡略化すると次のような形です。

const isValidStoriesListPath =
  from &&
  (
    /^\/stories\/?$/.test(from) ||
    /^\/stories\/page\/\d+\/?$/.test(from) ||
    /^\/stories\/oldest\/?$/.test(from) ||
    /^\/stories\/oldest\/page\/\d+\/?$/.test(from) ||
    /^\/categories\/[^/]+\/?$/.test(from) ||
    /^\/categories\/[^/]+\/page\/\d+\/?$/.test(from) ||
    /^\/levels\/[^/]+\/?$/.test(from) ||
    /^\/levels\/[^/]+\/page\/\d+\/?$/.test(from)
  );

実際にはカテゴリ・レベルの「古い順」も許可しています。

ここで大事なのは、

backLink.setAttribute('href', from);

を無条件に実行しないことです。

たとえばURLの from に外部URLや、サイト内でも戻り先として想定していない値が入っていた場合、それをそのままリンク先にする必要はありません。

「from があるか」ではなく、「from が許可した一覧パスのどれか」を確認してから採用するようにしています。

有効な場合だけ、初期値 /stories/ だったリンクを書き換えます。

if (storiesBackLink && isValidStoriesListPath) {
  storiesBackLink.setAttribute('href', from);
}

無効な値や from 自体がない場合は、通常の物語一覧へ戻るリンクがそのまま残ります。

前後の記事へ移動してもfromを消さない

詳細ページには「前の物語」「次の物語」へのナビゲーションもあります。

ここで次の記事へ移動した瞬間に from が消えると、2記事目から「物語一覧へ戻る」を押したときには一覧の先頭へ戻ってしまいます。

そのため、有効な from がある場合は前後ナビにも同じ値を引き継ぎます。

if (isValidStoriesListPath) {
  document.querySelectorAll('[data-story-neighbor-link]').forEach((link) => {
    const url = new URL(link.href);
    url.searchParams.set('from', from);
    link.setAttribute('href', `${url.pathname}${url.search}`);
  });
}

これで、

一覧3ページ目
  ↓
記事A
  ↓ 次の記事
記事B
  ↓ 次の記事
記事C
  ↓ 一覧へ戻る
一覧3ページ目

という流れでも、戻り先を維持できます。

from は「直前の記事」を示す値ではなく、一連の記事閲覧を始めた一覧の文脈として扱っています。

一覧の種類が増えてもStoryCard側は変えない

この構成では、記事カード自身は「自分が通常一覧にいるのか、カテゴリ一覧にいるのか」を知りません。

知っているのは、親ページから渡された listReturnPath だけです。

<StoryCard
  story={story}
  listReturnPath={listReturnPath}
/>

一覧ページ側が、

/stories/
/stories/page/2/
/categories/example/
/categories/example/oldest/page/2/
/levels/beginner/

のような現在位置を組み立て、共通の StoryCard へ渡します。

そのため、新しい一覧形式を追加するときも、StoryCardの内部へカテゴリ判定やページ番号判定を増やす必要はありません。

現在位置を知っているページが戻り先を作り、カードはそれを詳細URLへ渡すだけという分担にしています。

fromはUI用、canonicalは記事本体のURLにする

from を付けると、同じ記事に複数のURLができます。

/stories/example-story/
/stories/example-story/?from=%2Fstories%2F
/stories/example-story/?from=%2Fcategories%2Fexample%2F

ただし、これは記事内容を変えるためのパラメータではなく、戻り先を保持するUI上の状態です。

3分英語物語ではcanonical URLを作るとき、クエリ文字列を含むURL全体ではなく Astro.url.pathname を使っています。

const canonicalUrl = canonical && Astro.site
  ? new URL(Astro.url.pathname, Astro.site).toString()
  : undefined;

そのため、from が付いた状態で記事を開いても、canonicalは記事本体のURLを指します。

https://three-min-english.okunoapps.com/stories/example-story/

閲覧状態を維持するためのURLと、記事そのものを表す正規URLを分ける形です。

この方法で維持しているのは「一覧ページ」まで

この仕組みで戻せるのは、元いた一覧の種類、並び順、ページ番号までです。

一覧内でどこまでスクロールしていたかまでは from に保存していません。

そこまで完全に復元したいなら、

  • スクロール位置を別途保存する
  • History APIを使う
  • セッション内の状態として保持する

といった追加の仕組みが必要です。

今回は、実装を複雑にしすぎず、ページネーションで大きく位置を失う問題を解消することを優先しています。

また、一覧のURLパターンが今後さらに増える場合、正規表現を詳細ページへ並べ続けるより、許可パスの判定を専用関数へ切り出す方が管理しやすくなります。

戻るリンクもサイト内の導線として設計する

記事ページを作るときは、記事そのものの表示に意識が向きやすいですが、一覧型のサイトでは「読み終わったあと、どこへ戻るか」も導線の一部です。

今回の実装では、

  1. 一覧ページが自分のURLを listReturnPath として作る
  2. StoryCardが from として詳細URLへ渡す
  3. 詳細ページが from を検証する
  4. 有効なら「一覧へ戻る」のリンク先を変更する
  5. 前後の記事へ移動するときも from を維持する
  6. canonicalには from を含めない

という流れにしています。

大きな機能ではありませんが、ページネーションや絞り込みのあるコンテンツサイトでは、詳細ページへ入る前の文脈を失わせないだけでも一覧を続けて見やすくなります。