在前幾天的實作中,已經完成了計時器核心運作與計時結束後的紀錄 Modal。
但在實際的應用場景中,「計時」並非單獨存在,它必須精確地隸屬於某個「主任務」下的「子任務」。
在正式對接 Local-first 本地資料庫(如 IndexedDB)之前,我們需要先在記憶體中建立一套清晰的 State(狀態)管理機制,並完成主任務與子任務的 CRUD互動邏輯。
為了方便處理「刪除主任務時連帶刪除子任務」以及「計算子任務進度」,採用樹狀陣列物件結構:
let tasks = [
{
id: "task-1",
title: "iThome鐵人賽",
subtasks: [
{ id: "sub-1", title: "主題發想" },
{ id: "sub-2", title: "撰寫每日文章" },
{ id: "sub-3", title: "mockup製作" },
],
},
{
id: "task-2",
title: "JavaScript線上課程",
subtasks: [{ id: "sub-4", title: "觀看 DOM 章節影片" }],
},
];
設計優點:
層級關係明確:主任務直接持有 subtasks 陣列,資料極具直覺性。
自動連動刪除:刪除主任務時,其下方的 subtasks 會隨之被刪除,不用再撰寫額外語法。
主畫面使用了兩個 Bootstrap Dropdown 作為任務選擇器,並搭配兩個 Modal 視窗來分別處理「管理主任務」與「新增子任務」。
為了讓 JavaScript 能綁定 DOM 元素,確認 HTML 元件已帶入以下關鍵 id。
dropdown-main-task-btn / dropdown-main-task-menu
dropdown-subtask-btn / dropdown-subtask-menu
input-new-main-task / btn-add-main-task
input-new-subtask-name / btn-confirm-add-subtask
除了儲存完整任務樹的 tasks 外,我們還需要額外維持「當前選中」的狀態指標:
// 全域資料來源 (Single Source of Truth)
let tasks = [ /* ...初始任務資料... */ ];
// 當前主畫面選中的主任務與子任務 ID
let currentMainTaskId = tasks[0]?.id || null;
let currentSubtaskId = tasks[0]?.subtasks[0]?.id || null;
// 在「新增子任務 Modal」中暫時選擇的歸屬主任務 ID
let modalSelectedParentId = tasks[0]?.id || null;
在執行新增或修改時,使用者可能不知道或是不小心重複輸入相同名稱的任務。因此我們要利用 JavaScript 陣列的原生 .some() 方法進行重複檢測:
// 新增主任務
function addMainTask(title) {
const cleanTitle = title.trim();
if (!cleanTitle) return;
// 1. 重複名稱檢查
const isDuplicate = tasks.some((t) => t.title === cleanTitle);
if (isDuplicate) {
showToast(`新增失敗:已存在名為「${cleanTitle}」的主任務`, true);
return;
}
// 2. 寫入 State
const newId = `task-${Date.now()}`;
tasks.push({ id: newId, title: cleanTitle, subtasks: [] });
// 3. 自動選取防空機制
if (!currentMainTaskId) {
currentMainTaskId = newId;
currentSubtaskId = null;
}
showToast(`已成功新增主任務「${cleanTitle}」`);
renderAll(); // 觸發畫面更新
}
補充
在子任務新增的處理上,若重複或輸入空白,函式會回傳 false;只有在回傳 true(成功)時,事件監聽器才會清空輸入框並關閉 Modal。這樣能避免使用者不小心打錯字時,彈窗直接關閉導致需要重新打開。
建立 renderAll() 作為單一渲染驅動點。每當 State(如 tasks、currentMainTaskId)改變時,只要呼叫 renderAll(),整個 UI 就會自動響應更新:
// 統一渲染入口
function renderAll() {
renderManageMainTaskList();
renderMainTaskDropdown();
renderSubtaskDropdown();
renderModalParentDropdown();
}
// 範例:動態渲染「管理主任務」列表
function renderManageMainTaskList() {
if (!manageMainTaskList) return;
if (tasks.length === 0) {
manageMainTaskList.innerHTML = `<li class="p-3 text-center text-muted fs-sm">目前尚無主任務</li>`;
return;
}
// 使用 Array.map 轉為 HTML 字串並渲染
manageMainTaskList.innerHTML = tasks
.map(
(task) => `
<li class="p-2 border-bottom">
<div class="d-flex justify-content-between align-items-center">
<p class="fw-bold mb-0">${task.title}</p>
<div class="d-flex">
<button type="button" class="btn btn-outline-secondary rounded-circle me-2 btn-sm" onclick="updateMainTask('${task.id}')">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-outline-danger rounded-circle btn-sm" onclick="deleteMainTask('${task.id}')">
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</li>
`
)
.join("");
}
當使用者點擊主介面下拉選單切換主任務時,程式碼會自動重新指派子任務的預設值,並呼叫 renderAll() 進行雙向連動:
// 切換選中的主任務
function selectMainTask(taskId) {
currentMainTaskId = taskId;
const task = tasks.find((t) => t.id === taskId);
// 切換主任務時,預設自動選取該任務的第一個子任務
currentSubtaskId = task?.subtasks[0]?.id || null;
renderAll();
}
// 新增子任務 Modal 的確認點擊事件
btnConfirmAddSubtask.addEventListener("click", () => {
const isSuccess = addSubtask(modalSelectedParentId, inputNewSubtaskName.value);
// 只有新增成功時才清空並透過 Bootstrap API 手動關閉 Modal
if (isSuccess) {
inputNewSubtaskName.value = "";
const modalEl = document.querySelector("#addSubTask");
const modalObj = bootstrap.Modal.getInstance(modalEl);
if (modalObj) modalObj.hide();
}
});

單一事實來源(Single Source of Truth):
無論是在 Modal 裡新增/修改/刪除主任務,全域的 tasks 陣列才是唯一標準。不直接在 DOM 上進行「刪除節點」或「修改文字」的手動操作,而是修改 State 數據後統一執行 renderAll() 重新渲染。
連動下拉選單(Dynamic Dependent Dropdowns):
當切換「主任務」時,程式碼同步更新當前的 currentSubtaskId 並重新渲染子任務選單,有效避免了「主任務是 A,子任務選單卻顯示 B 的內容」這種狀態不一致的 Bug。
Modal 自動關閉體驗:
結合重複名稱的 showToast 警示,以及利用 bootstrap.Modal.getInstance(modalEl).hide() 的精準手動關閉,為使用者帶來更好的操作體驗。
個人筆記:
名詞介紹 | CRUD是什麼?4 個最基本的資料操作動作