2026年11月14日 星期六

JavaScript 入門教學(十六):綜合實作——做一個會記住資料的簡易記帳本

學了這麼多功能,最好的練習方式就是做一個完整的小專案。這一篇我們用前面學過的 DOM、事件、表單驗證、localStorage 與 JSON,做一個可以新增、刪除並記住資料的「簡易記帳本」。

一、功能與用到的技術

功能用到的技術對應文章
輸入項目與金額,按下新增表單、事件、FormData(二)、(八)
顯示清單與結餘DOM 建立元素、陣列方法(二)、(十二)
點擊刪除事件委派(二)
重新開啟網頁後資料還在localStorage、JSON(五)、(十二)
資料讀取失敗時不當機try...catch(十三)

二、步驟一:HTML 結構

<h2>簡易記帳本</h2>

<form id="ledgerForm">
  <input name="item" placeholder="項目" required maxlength="20">
  <input name="amount" type="number" placeholder="金額" min="1" step="1" required>
  <select name="type">
    <option value="out">支出</option>
    <option value="in">收入</option>
  </select>
  <button>新增</button>
</form>

<ul id="ledgerList"></ul>
<p>結餘:<span id="ledgerTotal">0</span> 元</p>

欄位的 required、min 等屬性會自動交給瀏覽器做基本驗證(第八篇)。

三、步驟二:讀取與儲存資料

const KEY = 'ledger-demo';
const form  = document.getElementById('ledgerForm');
const list  = document.getElementById('ledgerList');
const total = document.getElementById('ledgerTotal');

function load() {
  try {
    return JSON.parse(localStorage.getItem(KEY)) ?? [];
  } catch {
    return [];                         // 資料損壞時,從空清單開始
  }
}

function save() {
  localStorage.setItem(KEY, JSON.stringify(items));
}

let items = load();

localStorage.getItem 找不到資料會回傳 null,JSON.parse(null) 會得到 null,所以用 ?? [] 補上預設的空陣列。

四、步驟三:把資料畫到畫面上

function render() {
  list.replaceChildren();              // 先清空清單
  let sum = 0;

  items.forEach((it, i) => {
    const li = document.createElement('li');
    const sign = it.type === 'in' ? '+' : '-';
    li.textContent = it.item + ' ' + sign + it.amount.toLocaleString() + ' 元 ';

    const btn = document.createElement('button');
    btn.textContent = '刪除';
    btn.dataset.index = i;             // 記住這筆是第幾個
    li.append(btn);
    list.append(li);

    sum += it.type === 'in' ? it.amount : -it.amount;
  });

  total.textContent = sum.toLocaleString();
}

這裡用 textContent 而不是 innerHTML 來放使用者輸入的文字,可以避免有人輸入 HTML 或指令碼造成安全問題。

五、步驟四:新增與刪除

form.addEventListener('submit', (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(form));
  items.push({
    item: data.item.trim(),
    amount: Number(data.amount),
    type: data.type
  });
  save();
  render();
  form.reset();
});

// 事件委派:把監聽器放在清單上,不必替每個按鈕各綁一次
list.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-index]');
  if (!btn) return;
  items.splice(Number(btn.dataset.index), 1);
  save();
  render();
});

render();     // 開啟網頁時,先把已存的資料畫出來

六、完整程式碼(可存成 ledger.html 練習)

<!doctype html>
<meta charset="utf-8">
<title>簡易記帳本</title>
<h2>簡易記帳本</h2>
<form id="ledgerForm">
  <input name="item" placeholder="項目" required maxlength="20">
  <input name="amount" type="number" placeholder="金額" min="1" step="1" required>
  <select name="type"><option value="out">支出</option><option value="in">收入</option></select>
  <button>新增</button>
</form>
<ul id="ledgerList"></ul>
<p>結餘:<span id="ledgerTotal">0</span> 元</p>

<script>
const KEY = 'ledger-demo';
const form  = document.getElementById('ledgerForm');
const list  = document.getElementById('ledgerList');
const total = document.getElementById('ledgerTotal');

function load() {
  try { return JSON.parse(localStorage.getItem(KEY)) ?? []; }
  catch { return []; }
}
function save() { localStorage.setItem(KEY, JSON.stringify(items)); }

let items = load();

function render() {
  list.replaceChildren();
  let sum = 0;
  items.forEach((it, i) => {
    const li = document.createElement('li');
    li.textContent = it.item + ' ' + (it.type === 'in' ? '+' : '-') + it.amount.toLocaleString() + ' 元 ';
    const btn = document.createElement('button');
    btn.textContent = '刪除';
    btn.dataset.index = i;
    li.append(btn);
    list.append(li);
    sum += it.type === 'in' ? it.amount : -it.amount;
  });
  total.textContent = sum.toLocaleString();
}

form.addEventListener('submit', (e) => {
  e.preventDefault();
  const d = Object.fromEntries(new FormData(form));
  items.push({ item: d.item.trim(), amount: Number(d.amount), type: d.type });
  save(); render(); form.reset();
});

list.addEventListener('click', (e) => {
  const btn = e.target.closest('button[data-index]');
  if (!btn) return;
  items.splice(Number(btn.dataset.index), 1);
  save(); render();
});

render();
</script>

用記事本存成 ledger.html(編碼選 UTF-8),再用瀏覽器開啟就能使用。部分瀏覽器直接用檔案路徑開啟時,可能限制 localStorage,如果資料沒有被記住,請改用簡單的本機伺服器(例如編輯器的 Live Server 外掛)開啟。

七、驗算

新增三筆:「便當 支出 90」「薪水 收入 1000」「飲料 支出 60」,結餘應為 1000 − 90 − 60 = 850 元。刪除「飲料」後應變成 910 元。重新整理網頁,資料仍然存在,表示 localStorage 運作正常。

八、常見問題

  • 資料重新整理就不見:檢查 save() 是否有在新增與刪除後呼叫,以及瀏覽器是否處於無痕模式。
  • 刪錯筆:確認 render() 每次都重新設定 data-index,刪除後陣列位置會改變。
  • 想清空所有資料:執行 localStorage.removeItem('ledger-demo') 並重新整理。
  • 隱私提醒:localStorage 的資料只存在使用者自己的瀏覽器,換電腦或清除瀏覽資料就會消失,真正重要的帳務不應該只靠它保存。

九、延伸練習

  1. 加上「日期」欄位,並依日期由新到舊排序(可複習第十二篇)。
  2. 加上分類(餐飲、交通、娛樂),並統計各分類的支出總額。
  3. 支出超過一定金額時,把該筆文字用 CSS 類別標成紅色,並用 shake() 提示(第十五篇)。
  4. 加上「匯出資料」按鈕,把 items 轉成 JSON 下載。

結語

這個小專案把變數、陣列、函式、DOM、事件、表單、localStorage 與錯誤處理全部串在一起。如果你能不看範例,自己重新做出來,就代表你已經掌握了 JavaScript 的基礎。接下來可以挑一個自己有興趣的小題目,繼續練習與擴充。

沒有留言:

張貼留言