iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
生成式 AI

左手藍圖,右手魔法:DDD 與 Vibe Coding 的開發協奏曲系列 第 24

Day 24:【前端 #5】狀態管理的哲學:讓 UI 成為數據的鏡子

  • 分享至 

  • xImage
  •  

安安,我是 ChiYu!

昨天,我們的 App 經歷了一次歷史性的飛躍。我們為它安裝了「神經系統」,成功地讓前端的「身體」與後端的「大腦」進行了第一次對話。我們的 App 不再是離線的空殼,它擁有了「記憶」。

但是,這些寶貴的記憶,現在還只靜靜地躺在瀏覽器的 console 裡,像一本鎖在保險箱裡的日記,使用者完全無法窺見其貌。我們的 App 雖然有了記憶,但它還是一個「啞巴」,無法將自己的所思所想表達出來。

今天,我們就要來為 App 安裝「聲帶」和「表情肌肉」,教它如何開口說話。我們將深入探討一個區分業餘與專業前端開發的核心心法——狀態管理 (State Management),並引入一個輕量的「狀態中心」模式,讓我們的 UI 成為數據最忠實的鏡子。


Part 1:前端心法:為什麼「直接操作 DOM」是個壞主意?

你可能會想:「這很簡單啊!昨天拿到數據後,我用 JavaScript 的 document.createElementappendChild 這些方法,手動把一個個習慣項目加到畫面上不就好了嗎?」

問得好!這確實是一種方法,但它就像一個木偶戲的師傅,用線牽引著木偶(DOM 元素)的一舉一動。當只有一兩個木偶時,這套方法還行得通。但想像一下,你的 App 越來越複雜,畫面上同時有幾十個木偶,它們之間還有複雜的互動,這位可憐的師傅很快就會手忙腳亂,把線纏在一起,最終導致整場表演崩潰。

「直接操作 DOM」的壞處在於:

  1. 程式碼極度混亂:你的 script.js 將會充滿各種查找元素、新增元素、刪除元素、修改樣式的程式碼,很快就會變得難以閱讀和維護。
  2. 狀態不一致:你很可能會忘記更新某個地方的數字,導致畫面上顯示的數據,跟你內心(程式碼變數)裡記得的數據不一致,這就是 Bug 的主要來源。
  3. 難以追蹤:當 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. 狀態中心 (單一真理之源) ---
// 所有渲染 UI 所需的數據都儲存在這裡。
const state = {
  habits: [],
  isLoading: true, // 用於顯示載入指示器
  error: null, // 用於顯示任何獲取錯誤
};

// --- 2. 元素選擇器 ---
// 集中管理元素選擇可以提高效能和可讀性。
const habitListContainer = document.querySelector(".habit-list-container");

// --- 3. 主渲染引擎 ---
// 這是宣告式 UI 的核心。它根據當前狀態來渲染 UI。

/**
 * 根據中央 `state` 物件渲染習慣列表。
 */
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) => {
      // 在真實的應用中,`isCompleted` 應該來自於狀態
      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();
  // 未來像 renderMoodTracker() 這樣的渲染函式可以在這裡呼叫。
}

// --- 4. 狀態更新器 ---
// 一個集中的函式,用於更新狀態並觸發重新渲染。

/**
 * 將新狀態與現有狀態合併,並觸發重新渲染。
 * @param {object} newState - 一個包含要更新的狀態屬性的物件。
 */
function setState(newState) {
  // 將新狀態合併到舊狀態中
  Object.assign(state, newState);
  // 觸發重新渲染,以在 UI 中反映新狀態
  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 });
        console.log("✅ 習慣已載入並更新狀態:", state.habits);
      } else {
        // 處理來自 api.js 的失敗 (例如,fetch 回傳了 null)
        setState({ error: "讀取習慣失敗。", isLoading: false });
      }
    } catch (error) {
      // 未預期的錯誤
      console.error("在初始數據載入期間發生未預期的錯誤:", error);
      setState({ error: "發生未預期的錯誤。", isLoading: false });
    }
  }

  // --- 保留的邏輯 (不依賴於 `state.habits`) ---

  const addHabitBtn = document.getElementById("add-habit-btn");
  const addHabitModal = document.getElementById("add-habit-modal");
  const closeModalBtn = document.getElementById("close-modal-btn");
  const cancelBtn = document.getElementById("cancel-btn");
  const subtitle = document.getElementById("page-subtitle");
  const navLinks = document.querySelectorAll(".nav-item a");
  const pages = document.querySelectorAll(".page-content");

  const setTodaysDate = () => {
    const today = new Date();
    const options = { year: "numeric", month: "long", day: "numeric", weekday: "long" };
    if (subtitle) {
      subtitle.textContent = today.toLocaleDateString("zh-TW", options);
    }
  };

  const openModal = () => addHabitModal.classList.add("show");
  const closeModal = () => addHabitModal.classList.remove("show");

  if (addHabitBtn) addHabitBtn.addEventListener("click", openModal);
  if (closeModalBtn) closeModalBtn.addEventListener("click", closeModal);
  if (cancelBtn) cancelBtn.addEventListener("click", closeModal);
  if (addHabitModal) {
    addHabitModal.addEventListener("click", (event) => {
      if (event.target === addHabitModal) closeModal();
    });
  }

  const switchPage = (targetPageId) => {
    pages.forEach((page) => page.classList.add("hidden"));
    const targetPage = document.getElementById(targetPageId);
    if (targetPage) targetPage.classList.remove("hidden");
    navLinks.forEach((link) => {
      link.parentElement.classList.toggle("active", link.dataset.page === targetPageId);
    });
  };

  navLinks.forEach((link) => {
    link.addEventListener("click", (event) => {
      event.preventDefault();
      switchPage(link.dataset.page);
    });
  });

  // --- 應用程式初始化 ---

  setTodaysDate();
  switchPage("dashboard-page");
  loadInitialData(); // 開始數據獲取和渲染過程
});

設計解讀 (WHY WE DO THIS):

  • state 物件 - 為什麼是單一物件?

    WHY: 這是「單一真理之源」的具體實現。將所有與 UI 相關的數據集中管理,可以確保我們的程式碼中只有一個地方定義了「現在是什麼狀況」。這使得追蹤數據變化和除錯變得極其簡單。我們不再需要在不同的變數之間猜測哪個才是最新的。

  • setState() 函式 - 為什麼不直接 state.habits = data?

    WHY: setState 是我們狀態的唯一守門員。我們建立了一條規則:任何對 state 的修改,都必須通過 setState 這個函式。這樣做的好處是,我們鎖定了所有可能改變畫面的入口。更重要的是,它確保了每次狀態更新後,render() 都會被自動呼叫。這就是數據與畫面同步的魔法核心。

  • render() 函式 - 為什麼每次都要 innerHTML = '' 全部重畫?

    WHY: 這正是「宣告式」的精髓!我們不再關心「如何從舊畫面變成新畫面」,那太複雜了。我們用一種更簡單、更可靠的方式:直接扔掉舊的,然後根據全新的劇本 (state),畫一個全新的畫面。雖然聽起來有點暴力,但對於現代瀏覽器來說,這種操作的效能極高,而且它能 100% 保證畫面與數據的絕對一致性。

  • loadInitialData() - 為什麼要管理 isLoading 和 error?

    WHY: 一個專業的應用,必須處理數據請求的完整生命週期。isLoading 讓我們可以在等待數據時,給使用者一個明確的回饋(例如轉圈動畫),而不是讓他們對著白畫面發呆。error 則讓我們能在網路出錯時,給出友善的提示,而不是讓 App 直接崩潰。這體現了用戶體驗 (UX) 的專業考量。

Part 4:成果驗證:App 開口說話了!

是時候驗收了!請再次確保你的後端和前端伺服器都在運行。

刷新你的瀏覽器,這次,不再需要打開 console。你會親眼看到,那些來自我們資料庫的真實習慣數據,被完美地、動態地渲染到了我們的 UI 介面上!

我們的 App,第一次成功地將它的「記憶」,透過 UI 表達了出來!

Part 5:提交我們 App 的「靈魂」

  1. Commit 訊息: feat(frontend): Implement state management and render dynamic data
  2. Commit & Push

結語:App 有了思考,下一步是行動

再次恭喜!今天,我們的 App 經歷了一次質的飛躍。我們為它植入了「靈靈魂」——一個中央的狀態 (state) 和一個渲染引擎 (render)。它學會了如何「思考」(管理數據)並「表達」(渲染畫面)。

我們現在有了一個能夠反映真實數據的 App。但是,它還只能「讀」,不能「寫」。使用者還無法新增、修改或刪出這些習慣。

明天,我們將迎來一個內容極其豐富的實戰篇。我們將一天搞定所有習慣的「增、刪、改、查」與「打卡」功能,讓我們的 App 真正地「動」起來,完成核心功能的生命週期!


上一篇
Day 23: 【前端 #4】非同步的藝術:深入 Fetch API 與 Promise
下一篇
Day 25: 【前端 #6】核心生命週期:一天搞定習慣的「增刪改查」與「打卡」
系列文
左手藍圖,右手魔法:DDD 與 Vibe Coding 的開發協奏曲27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言