Day 24: 【前端 #5】狀態管理的哲學:讓 UI 成為數據的鏡子
發布於 2025年9月12日 (更新於 2025年9月12日) · 約 3 分鐘閱讀 · 2025iThomeIronman Gemini Frontend State Management JavaScript Data-Driven UI
安安,我是 ChiYu!
昨天,我們的 App 經歷了一次歷史性的飛躍。我們為它安裝了「神經系統」,成功地讓前端的「身體」與後端的「大腦」進行了第一次對話。我們的 App 不再是離線的空殼,它擁有了「記憶」。
但是,這些寶貴的記憶,現在還只靜靜地躺在瀏覽器的 console 裡,像一本鎖在保險箱裡的日記,使用者完全無法窺見其貌。我們的 App 雖然有了記憶,但它還是一個「啞巴」,無法將自己的所思所想表達出來。
今天,我們就要來為 App 安裝「聲帶」和「表情肌肉」,教它如何開口說話。我們將深入探討一個區分業餘與專業前端開發的核心心法——狀態管理 (State Management),並引入一個輕量的「狀態中心」模式,讓我們的 UI 成為數據最忠實的鏡子。
Part 1:前端心法:為什麼「直接操作 DOM」是個壞主意?
你可能會想:「這很簡單啊!昨天拿到數據後,我用 JavaScript 的 document.createElement、appendChild 這些方法,手動把一個個習慣項目加到畫面上不就好了嗎?」
問得好!這確實是一種方法,但它就像一個木偶戲的師傅,用線牽引著木偶(DOM 元素)的一舉一動。當只有一兩個木偶時,這套方法還行得通。但想像一下,你的 App 越來越複雜,畫面上同時有幾十個木偶,它們之間還有複雜的互動,這位可憐的師傅很快就會手忙腳亂,把線纏在一起,最終導致整場表演崩潰。
「直接操作 DOM」的壞處在於:
- 程式碼極度混亂:你的
script.js將會充滿各種查找元素、新增元素、刪除元素、修改樣式的程式碼,很快就會變得難以閱讀和維護。 - 狀態不一致:你很可能會忘記更新某個地方的數字,導致畫面上顯示的數據,跟你內心(程式碼變數)裡記得的數據不一致,這就是 Bug 的主要來源。
- 難以追蹤:當 Bug 出現時,你很難知道到底是哪一段手動操作,導致了畫面最終的錯誤狀態。
專業的作法:數據驅動畫面
那麼,專業的前端開發者是怎麼做的呢?他們不做那個辛苦的木偶師傅,而是做一位「劇本設計師」。
他們會建立一個唯一的**「劇本 (State)」,這個劇本用數據完整地描述了舞台上應該是什麼樣子。然後,他們會聘請一位叫做「渲染引擎 (Render Function)」的超級演員,這位演員的唯一工作,就是閱讀劇本,然後完美地將自己扮演成劇本描述的樣子**。
開發者的工作,從「手動去移動木偶的每一根手指」,變成了**「專心修改劇本」**。每當劇本(State)有任何變動,我們就大喊一聲:「卡!重來!」,然後渲染引擎就會立刻根據最新的劇本,重新表演一次,確保舞台上的畫面永遠與劇本 100% 同步。
這個「劇本」,就是我們的**「單一真理來源 (Single Source of Truth)」**。這個過程,就是 「數據驅動畫面 (Data-Driven UI)」 的核心哲學。
Part 2:Vibe Coding 實戰:建立我們的「狀態中心」與「渲染引擎」
好了,理論武裝完畢!讓我們進入 gemini chat 模式,指揮 AI 為我們重構昨天的邏輯,將這種專業的開發模式,注入我們的 App。
【魔法詠唱:植入 App 的靈魂】
我們將用一個比較長的 Prompt,一次性地指揮 AI 完成整個重構任務。
# 角色 (Role)
你是一位資深的 JavaScript 前端架構師,精通「數據驅動 UI」與「狀態管理」的設計模式。你擅長將傳統的命令式 DOM 操作,重構為以「單一真理之源 (Single Source of Truth)」為核心的現代化、宣告式 UI 架構。
# 目標 (Objective)
請對現有的 `@frontend/script.js` 進行一次架構性重構,**為應用程式注入一個輕量的「狀態管理」核心**。你需要建立一個中央的 `state` 物件、一個主 `render()` 函式以及一個 `setState()` 更新器,徹底改造應用程式的數據流與渲染機制,使其完全由狀態驅動。
# 上下文與關鍵資訊 (Context & Key Information)
* **目標重構檔案**: `@frontend/script.js` (其中已包含 Modal 邏輯和初版的數據獲取函式)。
* **依賴模組**: `@frontend/api.js` (提供 `fetchHabits` 函式)。
* **核心理念**: 我們的目標是讓 DOM 成為 `state` 的鏡像。我們不再手動操作 UI,而是只修改 `state`,然後由渲染引擎根據最新的 `state` 自動重繪介面。
# 產出格式與要求 (Your Task & Output Requirements)
請直接重寫 `@frontend/script.js` 的完整內容,並嚴格遵循以下所有架構設計步驟:
### **Part 1: 建立「狀態中心 (State Center)」**
* 在檔案的最上方,建立一個名為 `state` 的 `const` 物件,作為整個應用的「單一真理之源」。
* 初始 `state` 應包含**所有**描述 UI 可能狀態的屬性:
```javascript
const state = {
habits: [],
isLoading: true, // 用於顯示載入提示
error: null, // 用於顯示錯誤訊息
};
```
### **Part 2: 建立「主渲染引擎 (Main Render Engine)」**
* 建立一個名為 `render` 的主函式。此函式將成為未來所有渲染邏輯的入口。
* 在 `render` 函式內部,呼叫一個更具體的渲染函式,例如 `renderHabitList()`。
* 接著,建立 `renderHabitList` 函式,其**唯一職責**是根據 `state` 的當前內容,來渲染習慣列表:
* **清空容器**: 首先,清空 `.habit-list-container` 的現有內容。
* **處理載入狀態**: 如果 `state.isLoading` 為 `true`,則在容器中顯示載入提示(例如:`<p>Loading...</p>`),並提前返回。
* **處理錯誤狀態**: 如果 `state.error` 存在,則在容器中顯示錯誤訊息,並提前返回。
* **處理空狀態**: 如果 `state.habits` 陣列為空,則顯示一個友好的提示(例如:`<p>今天沒有習慣,快新增一個吧!</p>`)。
* **渲染列表**: 遍歷 `state.habits` 陣列,為每個 `habit` 物件生成對應的 HTML 列表項字串,最後將所有字串一次性插入到容器中。
### **Part 3: 建立「狀態更新器 (State Updater)」**
* 建立一個名為 `setState` 的函式,它接收一個 `newState` 物件作為參數。
* **更新邏輯**:
* 使用 `Object.assign(state, newState);` 將傳入的新狀態**淺合併**到現有的 `state` 物件中。
* **關鍵**: 在狀態更新之後,**立即呼叫主 `render()` 函式**,觸發 UI 的自動同步。
### **Part 4: 改造初始資料載入邏輯**
* 改造 `loadInitialData` 這個 `async` 函式,使其能夠管理完整的數據流狀態:
1. 在函式開始時,立即呼叫 `setState({ isLoading: true, error: null });` 進入載入狀態。
2. 在 `try` 區塊中,成功 `await fetchHabits()` 並獲取到 `data` 後,呼叫 `setState({ habits: data, isLoading: false });` 來更新中央狀態並結束載入。
3. 如果 `fetchHabits()` 返回 `null`,則呼叫 `setState({ error: 'Failed to load habits.', isLoading: false });`。
4. 在 `catch` 區塊中,捕獲到意外錯誤時,也應更新狀態:`setState({ error: 'An unexpected error occurred.', isLoading: false });`。
### **Part 5: 整合與執行**
* 確保在 `DOMContentLoaded` 事件中,`loadInitialData` 函式被正確呼叫,以啟動整個應用程式。
* **保留現有邏輯**: 確保 Modal 等其他不直接依賴 `state.habits` 的現有事件監聽器和功能被完整保留。
Part 3:程式碼深度解剖
AI 完成操作後,讓我們來仔細品味一下我們煥然一新的 script.js,看看它是如何實現「思考」與「表達」的。
import { fetchHabits } from "./api.js";
// --- 1. 狀態中心 (單一真理之源) ---
const state = {
habits: [],
isLoading: true,
error: null,
};
// --- 2. 元素選擇器 ---
const habitListContainer = document.querySelector(".habit-list-container");
// --- 3. 主渲染引擎 ---
function renderHabitList() {
habitListContainer.innerHTML = "";
if (state.isLoading) {
habitListContainer.innerHTML = "<p>正在載入您的習慣...</p>";
return;
}
if (state.error) {
habitListContainer.innerHTML = `<p class="error-message">${state.error}</p>`;
return;
}
if (state.habits.length === 0) {
habitListContainer.innerHTML = "<p>您尚未新增任何習慣。點擊「新增習慣」開始吧!</p>";
return;
}
const habitElements = state.habits
.map((habit) => {
const isCompleted = false; // 暫時寫死
return `
<div
class="habit-item ${isCompleted ? "completed" : ""}"
tabindex="0"
role="button"
aria-pressed="${isCompleted}"
data-habit-id="${habit.id}"
>
<div class="habit-checkbox">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
</div>
<span class="habit-name">${habit.name}</span>
</div>
`;
})
.join("");
habitListContainer.innerHTML = habitElements;
}
function render() {
renderHabitList();
}
// --- 4. 狀態更新器 ---
function setState(newState) {
Object.assign(state, newState);
render();
}
// --- 5. 應用程式邏輯與事件監聽器 ---
document.addEventListener("DOMContentLoaded", () => {
async function loadInitialData() {
setState({ isLoading: true, error: null });
try {
const data = await fetchHabits();
if (data) {
setState({ habits: data, isLoading: false });
} else {
setState({ error: "讀取習慣失敗。", isLoading: false });
}
} catch (error) {
console.error("在初始數據載入期間發生未預期的錯誤:", error);
setState({ error: "發生未預期的錯誤。", isLoading: false });
}
}
// 其他 UI 邏輯 (Modal 等) ...
// ...省略
loadInitialData();
});
設計解讀 (WHY WE DO THIS):
- state 物件 - 為什麼是單一物件?
- WHY: 這是「單一真理之源」的具體實現。將所有與 UI 相關的數據集中管理,可以確保我們的程式碼中只有一個地方定義了「現在是什麼狀況」。這使得追蹤數據變化和除錯變得極其簡單。
- setState() 函式 - 為什麼不直接 state.habits = data?
- WHY: setState 是我們狀態的唯一守門員。我們確保了每次狀態更新後,
render()都會被自動呼叫。這就是數據與畫面同步的魔法核心。
- WHY: setState 是我們狀態的唯一守門員。我們確保了每次狀態更新後,
- render() 函式 - 為什麼每次都要 innerHTML = ’’ 全部重畫?
- WHY: 這正是「宣告式」的精髓!我們不再關心「如何從舊畫面變成新畫面」。直接扔掉舊的,根據全新的劇本 (state),畫一個全新的畫面。
- loadInitialData() - 為什麼要管理 isLoading 和 error?
- WHY: 一個專業的應用,必須處理數據請求的完整生命週期。這體現了用戶體驗 (UX) 的專業考量。
Part 4:成果驗證:App 開口說話了!
是時候驗收了!刷新你的瀏覽器,這次,不再需要打開 console。你會親眼看到,那些來自我們資料庫的真實習慣數據,被完美地、動態地渲染到了我們的 UI 介面上!
我們的 App,第一次成功地將它的「記憶」,透過 UI 表達了出來!
Part 5:提交我們 App 的「靈魂」
- Commit 訊息:
feat(frontend): Implement state management and render dynamic data - Commit & Push!
結語:App 有了思考,下一步是行動
再次恭喜!今天,我們的 App 經歷了一次質的飛躍。它學會了如何「思考」(管理數據)並「表達」(渲染畫面)。
我們現在有了一個能夠反映真實數據的 App。但是,它還只能「讀」,不能「寫」。明天,我們將一天搞定所有習慣的「增、刪、改、查」與「打卡」功能,讓我們的 App 真正地「動」起來!