JavaScript勉強4日目:SWRキャッシュ/デバウンス&スロットル/並列fetch/アクセシビリティ

今日のゴール

  • **SWR(Stale-While-Revalidate)**で「速い+新しい」を両立
  • 入力はデバウンス、スクロールはスロットルでスムーズに
  • Promise.all並列fetch
  • 状態メッセージをaria-liveで読み上げ対応
    すべてバニラJS + ESM(ES Modules)で実装します。外部ライブラリ不要。

ファイル構成

js-day4/
├─ index.html
├─ styles.css
├─ api.js       # fetch + retry + SWRキャッシュ
├─ util.js      # debounce/throttle など小ヘルパ
└─ main.js      # 画面制御(並列fetch・検索・描画・進捗バー)

1) index.html

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>JavaScript 勉強 4日目</title>
  <link rel="stylesheet" href="./styles.css" />
</head>
<body>
  <header class="wrap">
    <h1>JavaScript 勉強 4日目</h1>
    <p class="muted">SWRキャッシュ / デバウンス&スロットル / 並列fetch / アクセシビリティ</p>
  </header>

  <main class="wrap grid">
    <section class="card">
      <h2>1) SWR(Stale-While-Revalidate)で軽快に</h2>
      <p>キャッシュがあれば<strong>即表示</strong>し、裏で最新データを取得して更新します。posts と users を並列に取得。</p>
      <div class="row">
        <button id="reload">最新に更新</button>
        <span id="status" class="muted" aria-live="polite"></span>
      </div>
      <div class="row">
        <input id="search" placeholder="タイトル/本文を検索(デバウンス0.3秒)" />
        <span class="muted">件数: <b id="count">0</b></span>
      </div>
      <ul id="list" class="posts"></ul>
    </section>

    <section class="card">
      <h2>2) 使っている設計</h2>
      <ul>
        <li><b>SWR</b>: まずキャッシュを返し、バックグラウンドで再取得し差分があれば更新</li>
        <li><b>デバウンス</b>: 入力が止まってから処理(無駄な再描画を抑える)</li>
        <li><b>スロットル</b>: スクロール進捗バー更新を一定間隔に制限</li>
        <li><b>並列fetch</b>: posts / users を <code>Promise.all</code> で同時取得</li>
        <li><b>アクセシビリティ</b>: ステータスメッセージは <code>aria-live="polite"</code></li>
      </ul>
    </section>
  </main>

  <div id="progress"></div>

  <footer class="wrap muted">© JS Day 4 Demo</footer>

  <script type="module" src="./main.js"></script>
</body>
</html>

2) styles.css

:root{
  --bg:#0b1227; --panel:#111827; --text:#e5e7eb; --muted:#9ca3af; --line:#1f2937; --accent:#60a5fa;
}
*{box-sizing:border-box}
html,body{margin:0;background:linear-gradient(135deg,#0b1227 0%,#1f2937 100%);color:var(--text);font-family:system-ui,-apple-system,'Segoe UI',Roboto,'Noto Sans JP',sans-serif}
.wrap{max-width:960px;margin:auto;padding:20px}
.grid{display:grid;gap:16px;grid-template-columns:1fr}
.card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:16px;box-shadow:0 10px 24px rgba(0,0,0,.25)}
h1,h2{margin:.2em 0 .6em}
.muted{color:var(--muted)}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
input,select,button{border:1px solid #334155;background:#0b1220;color:var(--text);padding:10px;border-radius:10px}
button{cursor:pointer}
ul{list-style:none;margin:0;padding:0;display:grid;gap:8px}
.posts li{background:#0b1220;border:1px solid #1f2937;border-radius:10px;padding:10px}
.posts .title{font-weight:700}
.posts .meta{color:#9ca3af;font-size:.9rem}
code{background:#0b1220;border:1px solid #1f2937;border-radius:8px;padding:2px 6px}
footer{font-size:.9rem;text-align:center;padding:32px 0}
#progress{position:fixed;left:0;top:0;height:3px;background:#60a5fa;width:0;z-index:50}
@media (min-width:860px){ .grid{grid-template-columns:1fr 1fr} }

3) api.js(fetch + retry + SWRキャッシュ)

// api.js — fetchのラッパ + リトライ + 簡易SWR(localStorage)

export class HTTPError extends Error {
  constructor(message, status, body) {
    super(message);
    this.name = 'HTTPError';
    this.status = status;
    this.body = body;
  }
}

export async function fetchWithRetry(url, options = {}, retryOptions = {}) {
  const {
    retries = 2,
    backoff = 400,
    factor = 2,
    jitter = true,
    timeout = 8000,
    retryOn = (err) => err instanceof TypeError || (err?.status >= 500),
  } = retryOptions;

  let attempt = 0;
  let lastErr;

  while (attempt <= retries) {
    const ac = new AbortController();
    const id = setTimeout(() => ac.abort(new Error('timeout')), timeout);
    try {
      const res = await fetch(url, { ...options, signal: ac.signal });
      clearTimeout(id);
      const text = await res.text();
      let data;
      try { data = text ? JSON.parse(text) : null; } catch { data = text; }
      if (!res.ok) throw new HTTPError(`HTTP ${res.status}`, res.status, data);
      return data;
    } catch (err) {
      clearTimeout(id);
      lastErr = err.name === 'AbortError' ? new Error('Request timeout') : err;
      if (attempt === retries || !retryOn(lastErr)) break;
      const pow = Math.pow(factor, attempt);
      let wait = backoff * pow;
      if (jitter) wait = Math.round(wait * (0.5 + Math.random()));
      await new Promise(r => setTimeout(r, wait));
      attempt++;
    }
  }
  throw lastErr;
}

// ===== 簡易SWRキャッシュ =====
const PREFIX = 'js-day4-cache:';

function getCache(key) {
  try {
    const raw = localStorage.getItem(PREFIX + key);
    if (!raw) return null;
    return JSON.parse(raw); // { ts, value }
  } catch { return null; }
}

function setCache(key, value) {
  const entry = { ts: Date.now(), value };
  localStorage.setItem(PREFIX + key, JSON.stringify(entry));
}

/**
 * swrJSON(key, url, { ttl, retryOptions })
 * - キャッシュを即返し(fromCache:true)、裏で再検証して差分あれば 'swr:update' をdispatch
 * - キャッシュが無ければ取得して保存→返す
 * - ttl(ms) を超えたキャッシュは「古い」扱いだが、即返しは維持(SWR)
 */
export async function swrJSON(key, url, { ttl = 60_000, retryOptions = {} } = {}) {
  const entry = getCache(key);
  if (entry) {
    const isExpired = Date.now() - entry.ts > ttl;

    // 裏で再検証(常に)
    queueMicrotask(async () => {
      try {
        const fresh = await fetchWithRetry(url, {}, retryOptions);
        if (JSON.stringify(fresh) !== JSON.stringify(entry.value)) {
          setCache(key, fresh);
          window.dispatchEvent(new CustomEvent('swr:update', { detail: { key, data: fresh } }));
        } else if (isExpired) {
          // 同じでも期限切れを更新しておく(tsだけ更新)
          setCache(key, fresh);
        }
      } catch (e) {
        // 静かに失敗
      }
    });

    return { fromCache: true, data: entry.value, expired: isExpired };
  }

  // キャッシュが無い → 取得して返す
  const fresh = await fetchWithRetry(url, {}, retryOptions);
  setCache(key, fresh);
  return { fromCache: false, data: fresh, expired: false };
}

4) util.js(デバウンス/スロットル/小ヘルパ)

// util.js — デバウンス & スロットル & 小ヘルパ
export function debounce(fn, wait=300) {
  let t;
  return (...args) => {
    clearTimeout(t);
    t = setTimeout(() => fn(...args), wait);
  };
}

export function throttle(fn, wait=100) {
  let last = 0, timer;
  return (...args) => {
    const now = Date.now();
    const remaining = wait - (now - last);
    if (remaining <= 0) {
      last = now;
      fn(...args);
    } else if (!timer) {
      timer = setTimeout(() => {
        last = Date.now();
        timer = null;
        fn(...args);
      }, remaining);
    }
  };
}

export const qs  = (sel, root=document) => root.querySelector(sel);
export const qsa = (sel, root=document) => Array.from(root.querySelectorAll(sel));

export function html(strings, ...vals){
  return strings.reduce((acc, s, i) => acc + s + (vals[i] ?? ''), '');
}

export function byId(list, key='id'){
  const map = new Map();
  for (const item of list) map.set(item[key], item);
  return map;
}

5) main.js(並列fetch+検索+描画+進捗バー)

// main.js — SWR + 並列fetch + デバウンス検索 + スロットル進捗バー
import { swrJSON, fetchWithRetry } from './api.js';
import { debounce, throttle, qs, html, byId } from './util.js';

const statusEl = qs('#status');
const listEl   = qs('#list');
const countEl  = qs('#count');
const searchEl = qs('#search');
const reloadBtn= qs('#reload');
const progress = qs('#progress');

let state = { posts: [], users: [], filtered: [] };

function setStatus(msg){ statusEl.textContent = msg; }

// 初期ロード(posts/users を並列&SWR)
init();

async function init(){
  setStatus('読み込み中…');
  performance.mark('init-start');
  const [postsRes, usersRes] = await Promise.all([
    swrJSON('posts', 'https://jsonplaceholder.typicode.com/posts'),
    swrJSON('users', 'https://jsonplaceholder.typicode.com/users')
  ]);
  state.posts = postsRes.data;
  state.users = usersRes.data;
  filterAndRender();
  performance.mark('init-end');
  performance.measure('初期描画', 'init-start', 'init-end');

  if (postsRes.fromCache || usersRes.fromCache) {
    setStatus('キャッシュから表示中 → 裏で更新…');
  } else {
    setStatus('最新データを表示中');
  }
}

// SWRのバックグラウンド更新通知
window.addEventListener('swr:update', (e) => {
  const { key, data } = e.detail || {};
  if (key === 'posts') state.posts = data;
  if (key === 'users') state.users = data;
  filterAndRender();
  setStatus('最新データに更新しました');
});

// 検索(デバウンス)
const onSearch = debounce(() => {
  filterAndRender();
}, 300);
searchEl.addEventListener('input', onSearch);

// リロード(強制最新)
reloadBtn.addEventListener('click', async () => {
  setStatus('最新データを取得中…');
  try{
    const [posts, users] = await Promise.all([
      fetchWithRetry('https://jsonplaceholder.typicode.com/posts'),
      fetchWithRetry('https://jsonplaceholder.typicode.com/users')
    ]);
    state.posts = posts;
    state.users = users;
    filterAndRender();
    setStatus('最新に更新しました');
    // キャッシュも更新
    localStorage.setItem('js-day4-cache:posts', JSON.stringify({ts:Date.now(), value:posts}));
    localStorage.setItem('js-day4-cache:users', JSON.stringify({ts:Date.now(), value:users}));
  }catch(e){
    console.error(e);
    setStatus('更新に失敗しました');
  }
});

function filterAndRender(){
  const q = (searchEl.value || '').trim().toLowerCase();
  const usersById = byId(state.users, 'id');

  const items = state.posts
    .filter(p => !q || p.title.toLowerCase().includes(q) || p.body.toLowerCase().includes(q))
    .map(p => ({
      id: p.id,
      title: p.title,
      body: p.body,
      author: usersById.get(p.userId)?.name || 'Unknown'
    }));

  state.filtered = items;
  renderList(items);
}

function renderList(items){
  countEl.textContent = String(items.length);
  const frag = document.createDocumentFragment();
  for (const it of items){
    const li = document.createElement('li');
    li.innerHTML = html`
      <div class="title">${escapeHTML(it.title)}</div>
      <div class="meta">by ${escapeHTML(it.author)}</div>
      <div class="body">${escapeHTML(it.body)}</div>
    `;
    frag.appendChild(li);
  }
  listEl.innerHTML = '';
  listEl.appendChild(frag);
}

// 簡易エスケープ(XSS対策)
function escapeHTML(str){
  const map = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'};
  return String(str).replace(/[&<>"']/g, s => map[s]);
}

// スクロール進捗バー(スロットル)
const onScroll = throttle(() => {
  const doc = document.documentElement;
  const scrollTop = doc.scrollTop || document.body.scrollTop;
  const height = doc.scrollHeight - doc.clientHeight;
  const ratio = height > 0 ? (scrollTop / height) : 0;
  progress.style.width = (ratio * 100).toFixed(2) + '%';
}, 50);
window.addEventListener('scroll', onScroll);

実行方法

  1. 上記ファイルを同じフォルダに保存
  2. index.html をダブルクリックで開く(または簡易サーバで配信)
  3. 「最新に更新」ボタンや検索を試して、SWR → 更新通知 → 再描画の流れを確認

仕上げメモ

  • SWRは「速い(キャッシュ)」と「新しい(裏で再取得)」の両立が狙い。UIは**イベント(swr:update)**で再描画。
  • デバウンスは入力が止まってから処理、スロットルは多発イベントを一定間隔に抑制。
  • 並列fetchは依存がないときの基本戦略。
  • アクセシビリティ:状態は aria-live="polite" に載せると読み上げ対応が向上。

発展課題(お好みで)

  • swrJSONttl を短くして挙動を比較
  • リトライ対象に 429(Rate Limit)や 503 を含める
  • 検索にハイライトを追加
  • 進捗バーを下端の粘着バー読み込みインジケータに発展
おすすめの記事