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);
});

金額フォーマット

// 通貨表示
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);
}

モーダル表示

// 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();
});

エラー表示ユーティリティ

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');

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);
});

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,
}));

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);
}

ステートマシン(注文フロー管理)

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'(レシート印刷)

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();  // サンドも追加された状態に戻る

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

遅延読み込み(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();