POS開発 フロントエンド チートコード — 実戦
9. 実用パターン
POS向け API呼び出しラッパー
const API_BASE = '/api'; const api = { async getProducts() { const res = await fetch(`${API_BASE}/products`); if (!res.ok) throw new Error("商品の取得に失敗"); return res.json(); }, async createOrder(orderData) { const res = await fetch(`${API_BASE}/orders`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(orderData), }); if (!res.ok) throw new Error("注文の送信に失敗"); return res.json(); }, async getSales(date) { const params = new URLSearchParams({ date }); const res = await fetch(`${API_BASE}/sales?${params}`); if (!res.ok) throw new Error("売上の取得に失敗"); return res.json(); }, };
デバウンス(検索入力の最適化)
// 入力が止まってから指定ms後に実行(API呼び出しを抑制) function debounce(fn, delay = 300) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), delay); }; } // 使い方 const searchProducts = debounce(async (query) => { const results = await api.search(query); renderResults(results); }, 300); searchInput.addEventListener('input', (e) => { searchProducts(e.target.value); });
// React: useState で入力即時反映、useEffect + setTimeout で確定値を遅延更新 // 「debouncedQuery が変わったとき」を検索トリガにする function ProductSearch() { const [query, setQuery] = useState(''); const [debouncedQuery, setDebouncedQuery] = useState(''); const [results, setResults] = useState([]); // 入力停止から 300ms で debouncedQuery を更新 useEffect(() => { const timer = setTimeout(() => setDebouncedQuery(query), 300); return () => clearTimeout(timer); // 入力のたびに前回をキャンセル }, [query]); // debounced 値が変わったら実際に検索 useEffect(() => { if (!debouncedQuery) { setResults([]); return; } api.search(debouncedQuery).then(setResults); }, [debouncedQuery]); return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)}/> <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul> </> ); }
金額フォーマット
// 通貨表示 const formatYen = (amount) => `¥${amount.toLocaleString()}`; formatYen(15800); // "¥15,800" // Intl.NumberFormatを使う(より正式) const formatter = new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY', }); formatter.format(15800); // "¥15,800" // 税込計算 const withTax = (price, rate = 0.1) => Math.floor(price * (1 + rate)); const taxOf = (price, rate = 0.1) => Math.floor(price * rate);
テーブル行の動的生成
function renderCartTable(items) { const tbody = document.getElementById('cartBody'); tbody.innerHTML = ''; // 一旦クリア items.forEach((item, i) => { const subtotal = item.price * item.qty; tbody.insertAdjacentHTML('beforeend', ` <tr data-index="${i}"> <td>${item.name}</td> <td>¥${item.price.toLocaleString()}</td> <td> <button class="qty-btn" data-action="decrease">-</button> <span>${item.qty}</span> <button class="qty-btn" data-action="increase">+</button> </td> <td>¥${subtotal.toLocaleString()}</td> <td><button class="delete-btn">削除</button></td> </tr> `); }); // 合計表示 const total = items.reduce((sum, item) => sum + item.price * item.qty, 0); document.getElementById('total').textContent = formatYen(total); }
// React: innerHTML クリア+再構築は不要。items を map で描画するだけ function CartTable({ items, onChangeQty, onRemove }) { const total = items.reduce((s, i) => s + i.price * i.qty, 0); return ( <> <table> <tbody> {items.map((item) => ( <tr key={item.id}> <td>{item.name}</td> <td>¥{item.price.toLocaleString()}</td> <td> <button onClick={() => onChangeQty(item.id, -1)}>−</button> <span>{item.qty}</span> <button onClick={() => onChangeQty(item.id, +1)}>+</button> </td> <td>¥{(item.price * item.qty).toLocaleString()}</td> <td> <button onClick={() => onRemove(item.id)}>削除</button> </td> </tr> ))} </tbody> </table> <p>合計 ¥{total.toLocaleString()}</p> </> ); }
モーダル表示
// HTML: <dialog id="confirmDialog">...</dialog> // dialog要素を使ったモーダル(ネイティブ対応) const dialog = document.getElementById('confirmDialog'); // 表示 dialog.showModal(); // モーダル表示(背景クリック不可) dialog.show(); // 非モーダル表示 // 閉じる dialog.close(); // 閉じた時の処理 dialog.addEventListener('close', () => { console.log(dialog.returnValue); // formのsubmitで設定された値 }); // ESCキーで閉じるのを防止 dialog.addEventListener('cancel', (e) => { e.preventDefault(); });
// React: 表示状態を state で持ち、条件付きレンダリング function ConfirmModal({ open, onClose, onConfirm, message }) { // ESC キーで閉じる(open が true のときだけ登録) useEffect(() => { if (!open) return; const onKey = (e) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [open, onClose]); if (!open) return null; // 非表示時は何もレンダリングしない return ( <div className="overlay" onClick={onClose}> <div className="dialog" onClick={(e) => e.stopPropagation()}> <p>{message}</p> <button onClick={onClose}>キャンセル</button> <button onClick={onConfirm}>OK</button> </div> </div> ); } // 使う側 function App() { const [open, setOpen] = useState(false); return ( <> <button onClick={() => setOpen(true)}>削除</button> <ConfirmModal open={open} message="本当に削除しますか?" onClose={() => setOpen(false)} onConfirm={() => { doDelete(); setOpen(false); }} /> </> ); }
エラー表示ユーティリティ
function showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; document.body.appendChild(toast); // アニメーション後に自動削除 setTimeout(() => { toast.classList.add('fade-out'); toast.addEventListener('animationend', () => toast.remove()); }, 3000); } // 使い方 showToast('商品を追加しました', 'success'); showToast('在庫が不足しています', 'error');
// React: トースト配列を state で管理。自動削除は setTimeout + cleanup function useToast() { const [toasts, setToasts] = useState([]); const showToast = (message, type = 'info') => { const id = Date.now() + Math.random(); setToasts((prev) => [...prev, { id, message, type }]); setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)); }, 3000); }; const ToastContainer = () => ( <div className="toast-container"> {toasts.map((t) => ( <div key={t.id} className={`toast toast-${t.type}`}> {t.message} </div> ))} </div> ); return { showToast, ToastContainer }; } // 使い方 function App() { const { showToast, ToastContainer } = useToast(); return ( <> <button onClick={() => showToast('商品を追加しました', 'success')}> 追加 </button> <ToastContainer /> </> ); }
POS実戦小技集(クイックレシピ)
早期リターン(Early Return)
// ガード節でネストを浅く保つ。else が消えて読みやすい function checkout(cart) { if (!cart) return; if (cart.items.length === 0) return alert("カートが空です"); if (!cart.user) return alert("ログインしてください"); // ↓ ここから本処理。深いネストが不要 return processOrder(cart); }
短絡評価(Short-circuit)使い分け
const name = user.name || "ゲスト"; // falsy("" や 0 も置換) const price = product.price ?? 0; // null/undefined のみ置換 isLoggedIn && showMenu(); // 真のときだけ実行 config.tax ??= 0.1; // 未設定なら代入 // ⚠️ "" や 0 を有効値として扱いたいときは ?? を使う const qty1 = input.value || 1; // "0" → 1 になってしまう const qty2 = input.value ?? 1; // "0" は "0" のまま
event.target 活用(dataset / closest / checked)
// イベント委譲+closest+dataset で行ごとのボタンを一括処理 list.addEventListener("click", (e) => { const btn = e.target.closest("button[data-action]"); if (!btn) return; const { action, id } = btn.dataset; if (action === "remove") removeItem(id); if (action === "plus") incrementQty(id); }); // input / checkbox / select も e.target で1関数にまとめる form.addEventListener("change", (e) => { const el = e.target; if (el.type === "checkbox") console.log(el.name, el.checked); else console.log(el.name, el.value); });
// React: map で描画するので、id や value は直接クロージャで受け取れる // closest / dataset で DOM から辿る必要がない function ItemList({ items, onRemove, onPlus }) { return ( <ul> {items.map((item) => ( <li key={item.id}> {item.name} <button onClick={() => onPlus(item.id)}>+</button> <button onClick={() => onRemove(item.id)}>削除</button> </li> ))} </ul> ); } // フォーム全体を 1 つの state object で管理([name] で動的更新) function SettingsForm() { const [form, setForm] = useState({ taxExempt: false, category: '' }); const handleChange = (e) => { const { name, type, value, checked } = e.target; setForm((prev) => ({ ...prev, [name]: type === 'checkbox' ? checked : value, })); }; return ( <form> <input type="checkbox" name="taxExempt" checked={form.taxExempt} onChange={handleChange}/> <input name="category" value={form.category} onChange={handleChange}/> </form> ); }
find + ガード(見つからない時の安全な扱い)
const item = cart.find(i => i.id === id); if (!item) return; // ガード節:以降は item が必ず存在 item.qty++; // optional chaining + ?? でワンライナー const name = cart.find(i => i.id === id)?.name ?? "不明な商品"; // findIndex で位置を取って分岐(あれば加算、なければ追加) const idx = cart.findIndex(i => i.id === id); if (idx === -1) cart.push(newItem); else cart[idx].qty++;
大文字小文字を無視した比較・検索
const q = query.toLowerCase(); const hit = products.filter(p => p.name.toLowerCase().includes(q)); // 一致比較もどちらかに揃える "ABC".toLowerCase() === "abc".toLowerCase(); // true code.toUpperCase() === "VIP".toUpperCase();
分割代入の実戦的な使い方
// 関数引数で受ける(順序を気にせず使える) function showProduct({ name, price, stock = 0 }) { console.log(`${name}: ¥${price} (在庫${stock})`); } showProduct({ name: "コーヒー", price: 300 }); // リネーム+デフォルト値 const { name: productName, price: productPrice = 0 } = product; // 配列:先頭と残り const [first, ...rest] = items;
スプレッド構文でイミュータブル更新
// 追加 const added = [...cart, newItem]; // 1件だけ更新 const updated = cart.map(i => i.id === id ? { ...i, qty: i.qty + 1 } : i ); // 削除 const removed = cart.filter(i => i.id !== id); // オブジェクトマージ(後ろが優先) const merged = { ...defaults, ...override };
Computed Property Name(動的キー)
// フォーム handleChange でフィールドを動的に更新 function handleChange(e) { setForm({ ...form, [e.target.name]: e.target.value }); } // 動的キーでオブジェクトを構築 const key = "tax"; const obj = { [key]: 0.1, [`${key}_label`]: "10%" }; // { tax: 0.1, tax_label: "10%" }
Object.entries / Object.fromEntries で変換
// オブジェクト → 配列 → 変換 → オブジェクト const prices = { apple: 100, banana: 80 }; const taxed = Object.fromEntries( Object.entries(prices).map(([k, v]) => [k, Math.floor(v * 1.1)]) ); // { apple: 110, banana: 88 } // 在庫があるものだけに絞る const inStock = Object.fromEntries( Object.entries(stock).filter(([_, qty]) => qty > 0) );
filter → map チェーン
// 在庫ありのドリンクだけ、税込価格に整形 const drinks = products .filter(p => p.category === "drink" && p.stock > 0) .map(p => ({ name: p.name, taxedPrice: Math.floor(p.price * 1.1), }));
reduce でグルーピング
// カテゴリ別に商品を分類 const grouped = products.reduce((acc, p) => { (acc[p.category] ??= []).push(p); return acc; }, {}); // { drink: [...], food: [...], snack: [...] } // カテゴリ別の売上合計 const total = sales.reduce((acc, s) => { acc[s.category] = (acc[s.category] || 0) + s.amount; return acc; }, {});
some / every(1つでも / 全部)
const hasOutOfStock = cart.some(i => i.stock === 0); // 1つでも在庫切れ? const allInStock = cart.every(i => i.stock > 0); // 全部在庫あり? const allTaxIncl = cart.every(i => i.taxIncluded); if (hasOutOfStock) alert("在庫切れの商品があります");
flatMap(map した結果を平坦化)
const orders = [ { items: ["コーヒー", "ケーキ"] }, { items: ["紅茶"] }, ]; const allItems = orders.flatMap(o => o.items); // ["コーヒー", "ケーキ", "紅茶"] // 条件付き展開(空配列を返すと除外と同じ効果) const taxables = products.flatMap(p => p.taxable ? [{ name: p.name, tax: Math.floor(p.price * 0.1) }] : [] );
Math.floor / round / ceil(消費税の丸め)
Math.floor(100 * 1.08); // 108 切り捨て(消費税は通常切り捨て) Math.round(100 * 1.08); // 108 四捨五入 Math.ceil(100 * 1.08); // 109 切り上げ // 消費税(10%、切り捨て) const tax = Math.floor(price * 0.1); const total = price + tax;
toLocaleString(通貨・数値・日付)
(1234567).toLocaleString(); // "1,234,567" 3桁カンマ (1234).toLocaleString("ja-JP", { style: "currency", currency: "JPY", }); // "¥1,234" new Date().toLocaleString("ja-JP"); // "2026/4/12 10:30:45" new Date().toLocaleDateString("ja-JP"); // "2026/4/12"
padStart / padEnd(伝票番号・列揃え)
"7".padStart(6, "0"); // "000007" 伝票番号ゼロ埋め "商品A".padEnd(10, " "); // "商品A " 列揃え const orderNo = String(id).padStart(8, "0"); // "00000123" const price = `¥${amount.toLocaleString()}`.padStart(10);
structuredClone(ディープコピー)
const original = { items: [{ name: "コーヒー", price: 300 }] }; const copy = structuredClone(original); copy.items[0].price = 999; // original.items[0].price は 300 のまま(参照を共有しない) // JSON.parse(JSON.stringify(...)) より高速・安全 // Date / Map / Set / undefined もOK(関数は不可)
レシート生成の実例(小技を組み合わせる)
function buildReceipt(cart) { // 1行ずつ整形(padEnd で列揃え、padStart で右寄せ) const lines = cart.map(i => { const name = i.name.padEnd(12, " "); const qty = `x${i.qty}`.padStart(4, " "); const sub = `¥${(i.price * i.qty).toLocaleString()}`.padStart(10); return `${name}${qty}${sub}`; }); // 集計(reduce で小計 → floor で消費税) const subtotal = cart.reduce((s, i) => s + i.price * i.qty, 0); const tax = Math.floor(subtotal * 0.1); const total = subtotal + tax; const no = String(Date.now()).slice(-8).padStart(8, "0"); return [ "=== レシート ===", `No.${no}`, new Date().toLocaleString("ja-JP"), "----------------", ...lines, "----------------", `小計 ¥${subtotal.toLocaleString().padStart(10)}`, `消費税(10%) ¥${tax.toLocaleString().padStart(10)}`, `合計 ¥${total.toLocaleString().padStart(10)}`, ].join("\n"); }
13. 状態管理パターン 中級
シンプルStore(状態の一元管理)
function createStore(initialState) { let state = { ...initialState }; const listeners = new Set(); return { getState() { return { ...state }; }, setState(updater) { const prev = state; state = { ...state, ...(typeof updater === 'function' ? updater(state) : updater) }; listeners.forEach(fn => fn(state, prev)); }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); // unsubscribe関数を返す }, }; } // 使い方 const store = createStore({ items: [], total: 0 }); // 変更を購読 const unsub = store.subscribe((newState, oldState) => { console.log('状態変更:', oldState, '→', newState); renderCart(newState); }); // 状態を更新 store.setState(s => ({ items: [...s.items, { name: 'コーヒー', price: 350 }], total: s.total + 350, }));
// React: Context + useReducer でアプリ全体の状態を一元管理 const CartContext = React.createContext(); function cartReducer(state, action) { switch (action.type) { case 'ADD': return { items: [...state.items, action.item], total: state.total + action.item.price, }; case 'CLEAR': return { items: [], total: 0 }; default: return state; } } function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, { items: [], total: 0 }); return ( <CartContext.Provider value={{ state, dispatch }}> {children} </CartContext.Provider> ); } // 購読側(useContext で値を取り出すだけ、自動で再レンダリング) function CartView() { const { state, dispatch } = useContext(CartContext); return ( <> <p>合計 ¥{state.total.toLocaleString()}</p> <button onClick={() => dispatch({ type: 'ADD', item: { name: 'コーヒー', price: 350 } })}> コーヒー追加 </button> </> ); }
Pub/Sub(EventEmitter)パターン
class EventEmitter { #handlers = {}; on(event, handler) { (this.#handlers[event] ??= []).push(handler); return this; // チェーン可能 } off(event, handler) { const list = this.#handlers[event]; if (list) this.#handlers[event] = list.filter(h => h !== handler); return this; } emit(event, ...args) { (this.#handlers[event] || []).forEach(h => h(...args)); } } // POS向け使用例 const bus = new EventEmitter(); // 表示コンポーネントが購読 bus.on('cart:updated', (items) => renderTable(items)); bus.on('cart:updated', (items) => updateTotal(items)); // カートに追加する処理が発火 function addToCart(product) { cart.push(product); bus.emit('cart:updated', cart); }
// React: Pub/Sub の役割はカスタムフック + Context で十分。 // 同じ state を複数コンポーネントに配るだけなら useContext で済む const CartContext = React.createContext(); function CartProvider({ children }) { const [cart, setCart] = useState([]); const addToCart = (product) => setCart((prev) => [...prev, product]); return ( <CartContext.Provider value={{ cart, addToCart }}> {children} </CartContext.Provider> ); } // 購読側1:テーブル表示(cart が変わると自動で再描画) function CartTable() { const { cart } = useContext(CartContext); return <ul>{cart.map((i, k) => <li key={k}>{i.name}</li>)}</ul>; } // 購読側2:合計表示(同じ Context を読むので 1 回の更新で両方再描画) function CartTotal() { const { cart } = useContext(CartContext); const total = cart.reduce((s, i) => s + i.price, 0); return <p>合計 ¥{total}</p>; }
ステートマシン(注文フロー管理)
function createMachine(config) { let current = config.initial; return { get state() { return current; }, send(event) { const transition = config.states[current]?.[event]; if (!transition) { console.warn(`無効な遷移: ${current} → ${event}`); return current; } const next = typeof transition === 'string' ? transition : transition.target; if (transition.action) transition.action(); current = next; return current; }, }; } // POS注文フロー const orderFlow = createMachine({ initial: 'idle', states: { idle: { START_SCAN: 'scanning' }, scanning: { ADD_ITEM: 'scanning', REVIEW: 'reviewing' }, reviewing: { PAY: 'paying', ADD_MORE: 'scanning' }, paying: { SUCCESS: { target: 'complete', action: () => printReceipt() }, FAIL: 'reviewing', }, complete: { RESET: 'idle' }, }, }); orderFlow.send('START_SCAN'); // → 'scanning' orderFlow.send('REVIEW'); // → 'reviewing' orderFlow.send('PAY'); // → 'paying' orderFlow.send('SUCCESS'); // → 'complete'(レシート印刷)
// React: useReducer が本来的にステートマシン。transition は reducer で表現 const transitions = { idle: { START_SCAN: 'scanning' }, scanning: { ADD_ITEM: 'scanning', REVIEW: 'reviewing' }, reviewing: { PAY: 'paying', ADD_MORE: 'scanning' }, paying: { SUCCESS: 'complete', FAIL: 'reviewing' }, complete: { RESET: 'idle' }, }; function orderReducer(state, event) { const next = transitions[state]?.[event]; if (!next) { console.warn(`無効な遷移: ${state} → ${event}`); return state; } return next; } function OrderFlow() { const [state, send] = useReducer(orderReducer, 'idle'); // paying → complete に入った瞬間にレシート印刷(副作用は useEffect) useEffect(() => { if (state === 'complete') printReceipt(); }, [state]); return ( <> <p>現在: {state}</p> <button onClick={() => send('START_SCAN')}>スキャン開始</button> <button onClick={() => send('REVIEW')}>確認</button> <button onClick={() => send('PAY')}>支払い</button> <button onClick={() => send('SUCCESS')}>成功</button> </> ); }
Undo/Redo(操作の取り消し・やり直し)
function createHistory(initialState) { const past = []; const future = []; let present = initialState; return { get state() { return present; }, get canUndo() { return past.length > 0; }, get canRedo() { return future.length > 0; }, push(newState) { past.push(present); present = newState; future.length = 0; // 新操作時にredoをクリア }, undo() { if (!this.canUndo) return present; future.push(present); present = past.pop(); return present; }, redo() { if (!this.canRedo) return present; past.push(present); present = future.pop(); return present; }, }; } // 使い方 const cartHistory = createHistory([]); cartHistory.push([{ name: 'コーヒー', qty: 1 }]); cartHistory.push([{ name: 'コーヒー', qty: 1 }, { name: 'サンド', qty: 2 }]); cartHistory.undo(); // コーヒーだけの状態に戻る cartHistory.redo(); // サンドも追加された状態に戻る
// React: useReducer で past / present / future を一括管理 function historyReducer(state, action) { const { past, present, future } = state; switch (action.type) { case 'PUSH': return { past: [...past, present], present: action.value, future: [] }; case 'UNDO': if (past.length === 0) return state; return { past: past.slice(0, -1), present: past[past.length - 1], future: [present, ...future], }; case 'REDO': if (future.length === 0) return state; return { past: [...past, present], present: future[0], future: future.slice(1), }; default: return state; } } function CartWithHistory() { const [state, dispatch] = useReducer(historyReducer, { past: [], present: [], future: [], }); const addItem = (item) => dispatch({ type: 'PUSH', value: [...state.present, item] }); return ( <> <button onClick={() => dispatch({ type: 'UNDO' })} disabled={state.past.length === 0}>元に戻す</button> <button onClick={() => dispatch({ type: 'REDO' })} disabled={state.future.length === 0}>やり直し</button> <ul>{state.present.map((i, k) => <li key={k}>{i.name}</li>)}</ul> </> ); }
15. パフォーマンス最適化 中級
スロットル(高頻度イベントの間引き)
function throttle(fn, interval) { let lastTime = 0; let timer = null; return function(...args) { const now = Date.now(); if (now - lastTime >= interval) { lastTime = now; fn.apply(this, args); } else { clearTimeout(timer); timer = setTimeout(() => { lastTime = Date.now(); fn.apply(this, args); }, interval - (now - lastTime)); } }; } // デバウンスとの違い: // debounce → 入力が止まってから実行(検索向き) // throttle → 一定間隔で実行(スクロール・リサイズ向き) // 使い方: バーコードスキャナの連続入力を間引く const handleScan = throttle((code) => { lookupProduct(code); }, 300);
DocumentFragment(一括DOM更新)
// ❌ 遅い — ループ内で毎回DOMに追加(リフローが毎回発生) products.forEach(p => { const li = document.createElement('li'); li.textContent = `${p.name} ¥${p.price}`; list.appendChild(li); // 毎回DOM操作が発生 }); // ✅ 速い — DocumentFragmentにまとめて一度だけ追加 const fragment = document.createDocumentFragment(); products.forEach(p => { const li = document.createElement('li'); li.textContent = `${p.name} ¥${p.price}`; fragment.appendChild(li); // メモリ上で構築 }); list.appendChild(fragment); // 1回だけDOM操作
requestAnimationFrame(スムーズなUI更新)
// 合計金額のカウントアップアニメーション function animateCounter(element, from, to, duration = 500) { const start = performance.now(); function update(currentTime) { const elapsed = currentTime - start; const progress = Math.min(elapsed / duration, 1); // イージング(ease-out) const eased = 1 - Math.pow(1 - progress, 3); const current = Math.floor(from + (to - from) * eased); element.textContent = `¥${current.toLocaleString()}`; if (progress < 1) { requestAnimationFrame(update); } } requestAnimationFrame(update); } // 使い方 const totalEl = document.getElementById('total'); animateCounter(totalEl, 0, 12500); // ¥0 → ¥12,500
// React: useEffect で rAF ループを回し、cleanup で cancelAnimationFrame // target が変わると自動的にキャンセル&再開 function AnimatedCounter({ target, duration = 500 }) { const [display, setDisplay] = useState(0); useEffect(() => { const from = display; const start = performance.now(); let rafId; const tick = (now) => { const progress = Math.min((now - start) / duration, 1); const eased = 1 - Math.pow(1 - progress, 3); setDisplay(Math.floor(from + (target - from) * eased)); if (progress < 1) rafId = requestAnimationFrame(tick); }; rafId = requestAnimationFrame(tick); return () => cancelAnimationFrame(rafId); // cleanup 必須 }, [target]); return <span>¥{display.toLocaleString()}</span>; } // 使い方:<AnimatedCounter target={12500} />
遅延読み込み(Lazy Loading)
// ✅ HTML属性だけで画像の遅延読み込み(最もシンプル) // <img src="product.jpg" loading="lazy" alt="商品画像"> // IntersectionObserverで自前実装(より細かい制御) function lazyLoad(selector = 'img[data-src]') { const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { const img = entry.target; img.src = img.dataset.src; img.removeAttribute('data-src'); observer.unobserve(img); } }); }, { rootMargin: '200px' }); // 200px手前で読み込み開始 document.querySelectorAll(selector).forEach(img => { observer.observe(img); }); } // ページ読み込み時に実行 lazyLoad();
// React: コンポーネント自体を lazy 化して、表示時にコード分割 // import() は動的インポート、初回表示まで JS を遅延ロード const ProductDetails = React.lazy(() => import('./ProductDetails')); const SalesChart = React.lazy(() => import('./SalesChart')); function App() { const [tab, setTab] = useState('list'); return ( <> <nav> <button onClick={() => setTab('list')}>一覧</button> <button onClick={() => setTab('details')}>詳細</button> <button onClick={() => setTab('chart')}>グラフ</button> </nav> {/* Suspense の fallback でローディング表示、 チャンクがロードされるまで待つ */} <Suspense fallback={<p>読み込み中...</p>}> {tab === 'details' && <ProductDetails />} {tab === 'chart' && <SalesChart />} </Suspense> </> ); } // 画像の遅延読み込みだけなら HTML 属性 loading="lazy" でOK: // <img src="product.jpg" loading="lazy" />