
今日のゴール
- **SWR(Stale-While-Revalidate)**で「速い+新しい」を両立
- 入力はデバウンス、スクロールはスロットルでスムーズに
Promise.allで並列fetch- 状態メッセージをaria-liveで読み上げ対応
すべてバニラJS + ESM(ES Modules)で実装します。外部ライブラリ不要。
Contents
ファイル構成
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 = {'&':'&','<':'<','>':'>','"':'"',"'":'''};
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);
実行方法
- 上記ファイルを同じフォルダに保存
index.htmlをダブルクリックで開く(または簡易サーバで配信)- 「最新に更新」ボタンや検索を試して、SWR → 更新通知 → 再描画の流れを確認
仕上げメモ
- SWRは「速い(キャッシュ)」と「新しい(裏で再取得)」の両立が狙い。UIは**イベント(
swr:update)**で再描画。 - デバウンスは入力が止まってから処理、スロットルは多発イベントを一定間隔に抑制。
- 並列fetchは依存がないときの基本戦略。
- アクセシビリティ:状態は
aria-live="polite"に載せると読み上げ対応が向上。
発展課題(お好みで)
swrJSONのttlを短くして挙動を比較- リトライ対象に
429(Rate Limit)や503を含める - 検索にハイライトを追加
- 進捗バーを下端の粘着バーや読み込みインジケータに発展









