iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

大家好~經過長達快一個月的鐵人賽,在最後的這四天,我要分三天來做todo list的程式實作,最後一天會為這次的鐵人賽做個總結。

三天的規劃如下:

  • day-27:先將基礎的todo list架構出來,包含新增、篩選分類區塊、勾選已完成目標和刪除等基礎功能
  • day-28:新增日曆和時間、可自由添加分類區塊功能
  • day-29:新增依日期及時間排序功能、優化並說明html、css

第一天整體架構:

todo-day1/
├── index.html
├── style.css
├── main.js        # 瀏覽器實際執行的JavaScript
└── src/
    └── main.ts    # TypeScript原始碼

詳細程式碼內容:

定義分類區塊

type Category = "work" | "personal";
type Filter = "all" | Category;

interface Todo {
  id: number;
  title: string;
  category: Category;
  completed: boolean;
}
  • Category:用聯合型別限制只能是 "work" 或 "personal"區塊。
  • Filter:直接復用Category,再加一個"all",這樣篩選條件跟分類的型別自然就是相容的,不用另外維護兩份字串。
  • Todo interface:定義一筆待辦事項的資料結構。
  • id:因為目前是簡單的前端練習,我這裡使用 Date.now()以目前時間為基礎的數字ID。
  • completed :布林值管理打勾狀態。

資料位置

let todos: Todo[] = [];
let currentFilter: Filter = "all";
  • todos:存取所有待辦事項的陣列。
  • currentFilter:記錄目前選到哪個篩選按鈕。因為還沒接資料庫,所以先用記憶體內的變數替代。

抓取&設定DOM元素

function getElement<T extends HTMLElement>(selector: string): T {
  const el = document.querySelector<T>(selector);
  if (!el) {
    throw new Error(`找不到元素:${selector}`);
  }
  return el;
}

const todoForm = getElement<HTMLFormElement>("#todo-form");
const todoInput = getElement<HTMLInputElement>("#todo-input");
const categorySelect = getElement<HTMLSelectElement>("#category-select");
const todoList = getElement<HTMLUListElement>("#todo-list");
const emptyMessage = getElement<HTMLParagraphElement>("#empty-message");
const filterButtons = document.querySelectorAll<HTMLButtonElement>(".filter-button");

這裡要特別說明一下,querySelector找不到元素時可能會回傳null,而且如果沒有指定泛型,TypeScript通常只能知道它是某種Element。如果沒有特別處理,之後變數後打 .value、.addEventListener會有紅波浪。為了避免這種情況發生,這裡寫了條件句處理,如果抓不到就直接丟錯誤,讓問題在一開始就丟出來。

表單送出&篩選按鈕

//送出表單
todoForm.addEventListener("submit", (event: SubmitEvent) => {
  event.preventDefault();
  addTodo();
});

function isFilter(value: string | undefined): value is Filter {
  return value === "all" || value === "work" || value === "personal";
}

//篩選按鈕
filterButtons.forEach((button) => {
  button.addEventListener("click", () => {
    const filter = button.dataset.filter;
    if (isFilter(filter)) {
      currentFilter = filter;
      updateActiveFilterButton(button);
      renderTodos();
    }
  });
});

四大功能:新增、篩選、切換、刪除

function addTodo(): void {
  const title = todoInput.value.trim();
  if (title === "") return;

  const category: Category = categorySelect.value === "work" ? "work" : "personal";

  todos.push({
    id: Date.now(),
    title,
    category,
    completed: false,
  });

  todoInput.value = "";
  renderTodos();
}

function toggleTodo(id: number): void {
  todos = todos.map((todo) =>
    todo.id === id ? { ...todo, completed: !todo.completed } : todo
  );
  renderTodos();
}

function deleteTodo(id: number): void {
  todos = todos.filter((todo) => todo.id !== id);
  renderTodos();
}

function filterTodos(): Todo[] {
  return currentFilter === "all"
    ? todos
    : todos.filter((todo) => todo.category === currentFilter);
}
  • addTodo:先使用trim()移除輸入內容前後的空白,再判斷是否為空字串。如果沒有輸入實際內容,就停止新增。分類的部分用三元運算子簡單判斷是work還是personal。新增完就清空輸入框,然後呼叫 renderTodos() 重新畫面。
  • toggleTodo跟deleteTodo:不直接改原本陣列裡的物件,而是用map和filter建立新的陣列,再重新指定給todos。
  • filterTodos:如果目前篩選是"all"就回傳全部,不然就用filter挑出分類相符的項目。回傳型別明確標成Todo[]。

渲染畫面

function renderTodos(): void {
  const visibleTodos = filterTodos();
  todoList.replaceChildren();
  emptyMessage.hidden = visibleTodos.length > 0;

  visibleTodos.forEach((todo) => {
    const item = document.createElement("li");
    item.className = "todo-item";
    
    const checkbox = document.createElement("input");
    checkbox.type = "checkbox";
    checkbox.checked = todo.completed;
    checkbox.setAttribute("aria-label", `完成任務:${todo.title}`);
    checkbox.addEventListener("change", () => toggleTodo(todo.id));
    
    const title = document.createElement("span");
    title.className = todo.completed ? "todo-title completed" : "todo-title";
    title.textContent = todo.title;
    
    const category = document.createElement("span");
    category.className = "todo-category";
    const categoryLabels: Record<Category, string> = {
      work: "工作",
      personal: "個人",
    };
    category.textContent = categoryLabels[todo.category];
    
    const deleteButton = document.createElement("button");
    deleteButton.type = "button";
    deleteButton.className = "delete-button";
    deleteButton.textContent = "刪除";
    deleteButton.addEventListener("click", () => deleteTodo(todo.id));
    
    item.append(checkbox, title, category, deleteButton);
    todoList.append(item);
  });
}
  1. filterTodos():拿到目前該顯示的待辦事項。
  2. todoList.replaceChildren():先把原本的待辦清單內容清空。這裡使用DOM API重新建立元素。另外,標題使用textContent設定,也能避免輸入內容被當成HTML解析。
  3. 用forEach把每一筆待辦事項組成一個 <li>,裡面包含勾選框、標題文字、分類標籤、刪除按鈕,逐一綁上對應的事件。

更新按鈕樣式

function updateActiveFilterButton(activeButton: HTMLButtonElement): void {
  filterButtons.forEach((button) => button.classList.remove("active"));
  activeButton.classList.add("active");
}

renderTodos();
  • updateActiveFilterButton:負責處理動態按鈕的樣式先把所有按鈕的active class拿掉,再幫目前點的那顆加上去。

小結

今天先將todo list的基礎功能完成,明天來新增一些常用的功能,像是日歷、時間等等,今天先到這邊啦,我們明天見!


上一篇
[Day-26]TypeScript常見錯誤與Debug
下一篇
[Day-28]todo list實作(2)
系列文
從零開始學習TypeScript:菜鳥的入門介紹與概覽 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言