ビューにロジックを置かない:データ組み立てはすべて src/server へ

ページにはルートの export とコンポーネントの読み込みしかありません。取得・パス組み立て・日付計算はすべて src/server に移しました。その利点と、強制される型の制約について。

このサイトには 40 以上のページがありますが、どの frontmatter も 3〜5 行です。ページが単純だからではなく、賢くなることを禁じているからです。

ひとつの規約

ページとコンポーネントの frontmatter では、データを取得しない・パスを組み立てない・値を加工しない。組み立てはすべて src/server/* に置き、各ページは完全なビューモデルを返す関数を export します。getStaticPaths は src/server/paths.ts が一括生成し、モデルは props で渡ります。

標準的なページはこうなります。

---
import '@/styles/pages/_blog.scss';
import { blogListPaths } from '@/server/paths';

export const getStaticPaths = blogListPaths;

const { page } = Astro.props;

const { default: BaseLayout } = await import('@/layouts/BaseLayout.astro');
const { default: PostList } = await import('@/components/PostList.astro');
---
<BaseLayout locale={page.locale} meta={page.meta}>
  <PostList cards={page.cards} empty={page.empty} />
</BaseLayout>

取得も map も new Date() も、文字列連結による URL 生成もありません。残るのは「このレイアウトとこのコンポーネントを使う」という宣言だけです。

コンポーネントで取得してはいけない理由

趣味ではなく、具体的な損失が三つあります。

第一に、同じ派生値が二度三度と計算されます。日付の表示、読了時間、カードのリンク、canonical のパス。どれか一つでもビューに残れば、一覧ページと記事ページで書き方が分岐します。

第二に、取得とテンプレートが混ざると「このページがなぜこのデータなのか」を単独で答える場所がなくなります。ビルド時のデータ不具合は、描画結果から逆算するしかありません。

第三に、多言語化が問題を増幅します。翻訳関数をテンプレートに散らすと、キーがページ数に比例して重複します。

ビューモデルの形

ブログ一覧なら BlogListPage が title subtitle empty cards pagination meta を一度に返します。ページネーションは「前後ページが null かどうか」まで決めてあるので、ビューは真偽を見るだけで計算しません。

「ページ送りがあるかどうか」はロジックであって表示ではないため、server 層が答えます。

型を最後まで運ぶという制約

getStaticPaths が返す PagePath<T> は型引数を保つ必要があります。

export interface PagePath<T> {
  params: Record<string, string>;
  props: { page: T };
}

ここを Record<string, unknown> に落とすと Astro.props がすべて unknown になり、page.cards が即座にエラーになります。楽をしようとする道は型検査がふさぐ——これは意図的に残しています。

代償

ページを一つ足すには三つのファイルを触ります。server/x.ts でモデル、paths.ts でルート、pages/x.astro で消費。ページ側で「ついでに」データを変えることはできません。変えるなら server に戻ります。

静的な文章だけのページなら、この分割は純粋なコストです。しかし一覧・ページ送り・三言語のどれか一つでもあれば、ビュー層の自由度はそのまま不整合の発生源になります。

← 記事一覧に戻る

コメント

…