1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポートフォリオ 100 件の landing ページを書いた — でも肝心なのはデータ駆動 + ランタイム fetch の設計

1
Posted at

きっかけ

100 件の OSS を公開する、という目標を立てました。公開ペースが上がってきたところで気づいたのは、一覧ページをどこに持つか という問題。

  • 会社の公式ホームページ (sen.ltd) に手書き HTML で一覧を持つと、エントリを追加するたびに本体のデプロイが必要
  • 一覧を静的 JSON にすると、今度はJSON の更新ごとに消費者側のビルドが発生

100 件に達することを考えると、この両方が摩擦の塊になります。答えは 「データと表示を完全に分離する」:

  • データ: portfolio/data/entries.json (single source of truth)
  • 表示: 実行時に fetch('/portfolio/data.json') するフロントエンドアプリ

このアプリ (portfolio-app-react) が、そのフロントエンド。React + TypeScript + Vite で書いた landing page 本体です。

作ったもの

Portfolio App (React)https://sen.ltd/portfolio/

スクリーンショット

ブラウズ機能フルセット:

  • フィルタ: カテゴリ / スタック / ステージ
  • 検索: 名前・ピッチ・タグどれでも
  • ソート: 番号 / 新着 / 古い順 / 名前順
  • URL クエリ同期: 絞り込み状態を共有リンク化
  • 日英 UI
  • ダーク UI (他の作品と統一感)

React 18 + TypeScript + Vite、gzip 49.00 kB。フィルタ・検索・ソートは pure 関数 (src/filter.ts) で実装、コンポーネント層から分離。

アーキテクチャの心臓部: ランタイム data.json fetch

普通の React アプリは、ビルド時に JSON を import してバンドルに含めます:

import data from '../data/entries.json'  // ← バンドル時に固定

本アプリは違う。実行時に fetch します:

const res = await fetch('/portfolio/data.json', { cache: 'no-store' })
const data = await res.json()

違いが決定的なのは、エントリ追加したときのデプロイフローです:

portfolio/data/entries.json に 1 行追加
           ↓
./deploy-site.sh _data prod   (JSON だけ S3 に upload, 2 秒で終わる)
           ↓
次回ユーザー訪問時に新データ反映(React アプリの再ビルド不要)

React アプリ側は一度 build したら触らない。100 件になっても 200 件になっても、アプリバンドルはそのまま。これが「データ駆動 landing」のキモ。

型定義は schema と一対一

data/entries.json の schema を TypeScript に写経:

export type Entry = {
  slug: string
  number: number
  name: LocalizedText
  pitch: LocalizedText
  stage: StageId
  source?: Source     // 'public' | 'closed' (SaaS 案件)
  category: string
  tech: string[]
  tags?: string[]
  github: string | null
  demo: string | null
  image: string | null
  articles: Article[]
  testCount?: number
  createdAt: string
}

export type PortfolioData = {
  version: string
  updatedAt: string
  entries: Entry[]
  categories: Category[]
  stacks: Stack[]
  stages: Stage[]
}

asserts 関数で実行時に検証してから使います:

function assertPortfolioData(raw: unknown): asserts raw is PortfolioData {
  if (typeof raw !== 'object' || raw === null) throw new Error('not an object')
  const obj = raw as Record<string, unknown>
  if (!Array.isArray(obj.entries)) throw new Error('entries missing')
  if (!Array.isArray(obj.categories)) throw new Error('categories missing')
  // ...
}

ランタイム fetch の型安全性は、ランタイム検証で守るのが正解。as PortfolioData で黙らせると、壊れたデータが降ってきた時に UI が不可解にクラッシュします。TypeScript の asserts 構文は、チェック後にナローイングしてくれるので書き心地もいい。

フィルタ・検索・ソートは純粋関数

src/filter.ts にまとめた pure 関数:

export function filterAndSort(
  entries: Entry[],
  filter: FilterState,
  lang: Lang
): Entry[] {
  const q = filter.query.trim().toLowerCase()
  const filtered = entries.filter((e) => {
    if (filter.category !== 'all' && e.category !== filter.category) return false
    if (filter.stack !== 'all' && !e.tech.includes(filter.stack)) return false
    if (filter.stage !== 'all' && e.stage !== filter.stage) return false
    if (q) {
      const hay = (e.name[lang] + ' ' + e.pitch[lang] + ' ' + (e.tags ?? []).join(' ')).toLowerCase()
      if (!hay.includes(q)) return false
    }
    return true
  })
  return sortEntries(filtered, filter.sort)
}

これをコンポーネントから分離しておくと、React の外でテストできる:

import { filterAndSort } from './filter'
// Vitest で pure 関数として直接テスト

14 テスト、すべて React なしで動く。コンポーネントはデータを受け取って描画するだけにする設計原則。

URL クエリ同期でシェアラブルに

絞り込み状態を URL に書き戻します:

function writeQuery(filter: FilterState, lang: Lang) {
  const q = new URLSearchParams()
  if (filter.query) q.set('q', filter.query)
  if (filter.category !== 'all') q.set('category', filter.category)
  if (filter.stack !== 'all') q.set('stack', filter.stack)
  // ...
  q.set('lang', lang)
  window.history.replaceState(null, '', `${window.location.pathname}?${q.toString()}`)
}

history.replaceState を使って、履歴を汚さない。「React の案件だけ表示」した URL を友人にシェアできる。

逆に、URL からの読み込み:

function readQuery(): FilterState {
  const q = new URLSearchParams(window.location.search)
  return {
    query: q.get('q') ?? '',
    category: q.get('category') ?? 'all',
    stack: q.get('stack') ?? 'all',
    stage: q.get('stage') ?? 'all',
    sort: (q.get('sort') as SortKey) ?? 'number',
  }
}

これで ?category=dev-tool&sort=newest みたいなリンクが state を復元してくれます。

SaaS 案件のバッジ対応

OSS 目標の 100 カウントに混ぜたくないけど見せたい案件(ソース非公開の自作 SaaS)も受け入れるため、source: 'closed' フィールドをサポート:

{entry.source === 'closed' && (
  <span className="source-badge">🔒 Closed source</span>
)}

そして header の meta line で OSS と SaaS をカウント分離:

{(() => {
  const saas = data.entries.filter((e) => e.source === 'closed').length
  const oss = data.entries.length - saas
  return m.totalCount(oss, saas)  // "OSS 30 件 · SaaS 1 件"
})()}

「100 件の OSS を目指す」目標と、「現実に持っている SaaS を載せる」要求を両立できる仕組み。データモデルに optional field を足すだけ で、既存の 30 エントリに影響を与えずに拡張できた。

フレームワーク比較シリーズの anchor

このアプリは 「同じ仕様・同じデータ・同じ CSS を複数のフレームワークで実装して比較する」 シリーズの 1 本目でもあります:

  • 021 React (このエントリ) — gzip 49.00 kB
  • 022 Vue 3 — gzip 28.76 kB (−41%)
  • 023 Svelte 5 — gzip 18.92 kB (−61%)
  • 024 Solid — gzip 8.33 kB (−83%)
  • 025 Nuxt 3 — gzip 52.01 kB (+7%) ← 唯一 React より大きい
  • 026 SvelteKit — gzip 32.50 kB (−33%)
  • 027 Qwik — first-paint gzip 28.60 kB (−41%)
  • 028 Astro islands — gzip 3.17 kB (−94%, 現時点で最小)
  • 029 Lit 3 — gzip 9.70 kB (−80%)
  • 030 Preact — gzip 8.75 kB (−82%)

共通コード (types.ts, filter.ts, data.ts, style.css, tests) は byte-identical、コンポーネント層だけが変わる設計。これにより「同じ仕事を各フレームワークがどれだけのバイトでこなすか」を純粋に比較できます。詳細は各実装の記事で。

テスト

Vitest で 14 ケース。filter.ts の pure 関数に対するテストが中心:

test('filters by category', () => {
  const r = filterAndSort(entries, { ...defaults, category: 'dev-tool' }, 'ja')
  assert.ok(r.every((e) => e.category === 'dev-tool'))
})

test('searches by pitch', () => {
  const r = filterAndSort(entries, { ...defaults, query: 'cron' }, 'ja')
  assert.ok(r.some((e) => e.slug === 'cron-tz-viewer'))
})

test('sorts by newest', () => {
  const r = filterAndSort(entries, { ...defaults, sort: 'newest' }, 'ja')
  for (let i = 0; i + 1 < r.length; i++) {
    assert.ok(r[i].createdAt >= r[i + 1].createdAt)
  }
})

全テストが React レイヤー抜きで走るので、実行は秒単位。UI テストと分離する のは長期メンテで効いてくる設計。

おわりに

SEN 合同会社の ポートフォリオシリーズ 100+ の 21 件目です。landing page 本体なので、シリーズ全体の基盤エントリ。

次回(022〜)で、同じ仕様を Vue・Svelte・Solid 等に実装した記事が続きます。フレームワークのバイトサイズ比較に興味がある方はシリーズで。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?