POS開発 フロントエンド チートコード — 品質

11. エラーハンドリング応用 中級

カスタムErrorクラス

class AppError extends Error {
  constructor(message, code) {
    super(message);
    this.name = this.constructor.name;
    this.code = code;
  }
}

class PaymentError extends AppError {
  constructor(message, code, transactionId) {
    super(message, code);
    this.transactionId = transactionId;
  }
}

class StockError extends AppError {}

// 使い分け
try {
  throw new PaymentError('決済失敗', 'PAY_001', 'txn_abc');
} catch (e) {
  if (e instanceof PaymentError) {
    console.log(`決済エラー [${e.code}]: ${e.message}`);
  } else if (e instanceof StockError) {
    console.log(`在庫エラー: ${e.message}`);
  } else {
    throw e;  // 未知のエラーは再throw
  }
}

リトライ(指数バックオフ)

async function retryFetch(url, options = {}, maxRetries = 3) {
  let lastError;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const res = await fetch(url, options);
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      lastError = err;
      if (attempt < maxRetries - 1) {
        // 待機時間: 1秒 → 2秒 → 4秒...
        const delay = 1000 * Math.pow(2, attempt);
        console.log(`リトライ ${attempt + 1}/${maxRetries}(${delay}ms後)`);
        await new Promise(r => setTimeout(r, delay));
      }
    }
  }

  throw new Error(`${maxRetries}回リトライ後も失敗: ${lastError.message}`);
}

// 使い方
const data = await retryFetch('/api/orders');

グローバルエラーハンドリング

// 同期エラーのキャッチ
window.addEventListener('error', (e) => {
  console.error('未処理エラー:', e.message, e.filename, e.lineno);
  // サーバーにエラーレポートを送信
  fetch('/api/error-log', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message: e.message,
      source: e.filename,
      line: e.lineno,
      time: new Date().toISOString(),
    }),
  });
});

// 未処理のPromise rejectionのキャッチ
window.addEventListener('unhandledrejection', (e) => {
  console.error('未処理Promise:', e.reason);
  e.preventDefault();  // コンソールのデフォルトエラー表示を抑制
});

エラー境界パターン(操作全体をラップ)

async function safePay(orderId, amount) {
  try {
    // 1. 在庫チェック
    await checkStock(orderId);
    // 2. 決済実行
    const result = await processPayment(orderId, amount);
    // 3. 完了通知
    showToast('決済完了', 'success');
    return result;

  } catch (e) {
    if (e instanceof StockError) {
      showToast(`在庫不足: ${e.message}`, 'error');
    } else if (e instanceof PaymentError) {
      showToast(`決済失敗: ${e.message}`, 'error');
      // 決済ロールバック
      await rollbackPayment(e.transactionId);
    } else {
      showToast('予期しないエラーが発生しました', 'error');
    }
    console.error('safePay失敗:', e);
    return null;
  }
}

12. フォームバリデーション 中級

Constraint Validation API

// HTML側: <input type="number" id="price" min="0" max="999999" required>
const priceInput = document.getElementById('price');

// 標準バリデーション
priceInput.checkValidity();    // true / false
priceInput.reportValidity();   // ブラウザのツールチップを表示

// カスタムバリデーション
priceInput.addEventListener('input', () => {
  const value = Number(priceInput.value);

  if (value % 10 !== 0) {
    priceInput.setCustomValidity('価格は10円単位で入力してください');
  } else {
    priceInput.setCustomValidity('');  // バリデーションOK
  }
});

// validity プロパティで詳細を確認
const v = priceInput.validity;
v.valueMissing;    // required未入力
v.typeMismatch;    // type不一致
v.rangeUnderflow;  // min未満
v.rangeOverflow;   // max超過
v.customError;     // setCustomValidityでエラーあり

リアルタイムバリデーション

function setupValidation(input, validateFn) {
  const errorEl = document.createElement('span');
  errorEl.className = 'field-error';
  errorEl.style.color = '#e94560';
  errorEl.style.fontSize = '12px';
  input.after(errorEl);

  function validate() {
    const error = validateFn(input.value);
    errorEl.textContent = error || '';
    input.style.borderColor = error ? '#e94560' : '';
    return !error;
  }

  input.addEventListener('blur', validate);
  input.addEventListener('input', () => {
    if (errorEl.textContent) validate();  // エラー中は即時再検証
  });

  return validate;
}

// 使い方
const validateQty = setupValidation(
  document.getElementById('quantity'),
  (val) => {
    if (!val) return '数量を入力してください';
    if (Number(val) < 1) return '1以上を入力してください';
    if (Number(val) > 99) return '99以下で入力してください';
    return null;
  }
);

フォーム全体の検証

const form = document.getElementById('paymentForm');

form.addEventListener('submit', (e) => {
  e.preventDefault();

  // 全フィールドの標準バリデーション
  if (!form.checkValidity()) {
    form.reportValidity();
    return;
  }

  // カスタムクロスフィールド検証
  const paid = Number(form.paidAmount.value);
  const total = Number(form.totalAmount.value);

  if (paid < total) {
    form.paidAmount.setCustomValidity('支払額が合計未満です');
    form.reportValidity();
    form.paidAmount.setCustomValidity('');  // 次回のためにリセット
    return;
  }

  // お釣り計算して送信
  const change = paid - total;
  console.log(`お釣り: ¥${change.toLocaleString()}`);
  submitOrder(form);
});

サニタイズ(XSS防止の入力処理)

// HTMLエスケープ
function escapeHTML(str) {
  const div = document.createElement('div');
  div.appendChild(document.createTextNode(str));
  return div.innerHTML;
}

// 数値のみ許可する入力制限
function numericOnly(input) {
  input.addEventListener('input', () => {
    input.value = input.value.replace(/[^0-9]/g, '');
  });
}

// 長さ制限付きサニタイズ
function sanitizeInput(value, maxLength = 100) {
  return escapeHTML(value.trim().slice(0, maxLength));
}

// 使い方(商品名を安全に表示)
const rawName = '<script>alert("XSS")</script>弁当';
cell.textContent = rawName;          // ✅ textContent は安全
cell.innerHTML = escapeHTML(rawName); // ✅ エスケープ済み
cell.innerHTML = rawName;            // ❌ XSS脆弱性!

14. セキュリティとXSS対策 中級

textContent vs innerHTML(XSS防止の基本)

const userInput = '<img src=x onerror=alert("XSS")>';
const el = document.getElementById('output');

// ✅ 安全 — テキストとして表示、HTMLとして解釈されない
el.textContent = userInput;

// ❌ 危険 — HTMLとして解釈され、スクリプトが実行される
el.innerHTML = userInput;

// ✅ 安全にHTMLを構築する方法
const li = document.createElement('li');
li.textContent = userInput;  // テキストのみ設定
list.appendChild(li);

HTMLエスケープユーティリティ

const ESCAPE_MAP = {
  '&': '&amp;',
  '<': '&lt;',
  '>': '&gt;',
  '"': '&quot;',
  "'": '&#39;',
};

function escapeHTML(str) {
  return str.replace(/[&<>"']/g, (ch) => ESCAPE_MAP[ch]);
}

// innerHTML に挿入する必要がある場合に使用
const productName = '<script>悪意あるコード</script>';
row.innerHTML = `<td>${escapeHTML(productName)}</td>`;

安全なJSON解析とeval禁止

// ❌ eval は絶対に使わない(任意コード実行のリスク)
const data = eval('(' + jsonString + ')');  // 危険!

// ✅ JSON.parse を使う
try {
  const data = JSON.parse(jsonString);
} catch (e) {
  console.error('不正なJSONデータ:', e.message);
}

// ✅ バーコードデータも安全に処理
function parseBarcode(raw) {
  const cleaned = raw.replace(/[^0-9]/g, '');  // 数字以外を除去
  if (cleaned.length !== 13) return null;
  return cleaned;
}

機密データの安全な取り扱い

// 決済後にカード情報をDOMとメモリから消去
function clearSensitiveData() {
  // DOM上の値をクリア
  const cardInput = document.getElementById('cardNumber');
  if (cardInput) {
    cardInput.value = '';
    cardInput.setAttribute('autocomplete', 'off');
  }

  // マスキング表示(下4桁のみ)
  const display = document.getElementById('cardDisplay');
  if (display) display.textContent = '**** **** **** 1234';
}

// カード番号のマスキング関数
function maskCardNumber(num) {
  const last4 = num.slice(-4);
  return `**** **** **** ${last4}`;
}

// localStorageに機密情報を保存しない
// ❌ localStorage.setItem('cardNumber', '4111111111111111');
// ✅ サーバー側で安全に管理し、トークンのみ保持
localStorage.setItem('paymentToken', 'tok_abc123');