第 14 章:React 哲學與最佳實踐

14.1 React 的思考方式:元件化拆分 UI

React 的核心思想是將使用者介面(UI)拆分成獨立、可重用的部分,也就是「元件」(Components)。這種方法不僅使程式碼更有組織,也提升了可維護性和開發效率。當我們思考一個 React 應用程式時,應該從元件的角度出發,遵循以下步驟:

  1. 繪製 UI 藍圖並劃分元件層級(Mockup and Component Hierarchy): 首先,與設計師合作(如果有的話)或自己勾勒出應用程式的視覺藍圖。然後,將這個藍圖上的每一個視覺區塊都圈出來,給它們命名。這些圈出來的區塊就是你的元件的候選者。思考它們之間的層級關係:哪些元件是其他元件的子嗣?

    • 範例:假設我們要建置一個簡單的筆記應用程式界面。
      • 最外層可能是 App 元件。
      • App 裡面可能有一個 Header 元件(包含 Logo 和搜尋框)。
      • Header 內的搜尋框可以是 SearchBar 元件。
      • App 裡面還有一個 Notebook 元件。
      • Notebook 包含一個 NoteList 元件(顯示筆記列表)和一個 NoteEditor 元件(用於撰寫或編輯筆記)。
      • NoteList 中的每一則筆記摘要可以是 NoteItem 元件。 這種由大到小、由外到內的拆分方式,有助於形成清晰的元件樹。
  2. 建立靜態版本(Build a Static Version): 此階段的目標是根據已定義的資料模型,將 UI 完美呈現出來,但不包含任何互動性。最好是將資料流動與 UI 渲染分開處理。你可以選擇:

    • 由上而下(Top-down):從元件樹的頂層(如 App)開始建構,逐步向下實作子元件。
    • 由下而上(Bottom-up):從最基礎、最細粒度的元件(如 NoteItem 或按鈕)開始,然後將它們組合成更複雜的父元件。 對於大型專案,由下而上通常更容易測試和管理。在這個階段,大量使用 props 來傳遞資料。例如,NoteList 會接收一個筆記陣列的 prop,然後遍歷這個陣列,為每個筆記渲染一個 NoteItem,並將單個筆記的資料作為 prop 傳給 NoteItem
  3. 識別最小且完整的狀態表示(Identify the Minimal (but complete) Representation of UI State): 現在,思考應用程式中所有會隨時間改變的資料。哪些是應用程式的「狀態」(state)?關鍵在於DRY (Don't Repeat Yourself) 原則。找出構成應用程式所需狀態的最小集合,避免冗餘。任何可以從其他狀態計算出來的資料,都不應該成為獨立的狀態。

    • 範例(續前)
      • 原始的筆記列表 (allNotes)。
      • 使用者在 SearchBar 中輸入的搜尋文字 (searchText)。
      • 當前在 NoteEditor 中被選中編輯的筆記 ID (selectedNoteId)。
      • NoteEditor 中正在輸入的筆記內容 (editorContent)。
    • 不應該是狀態的例子
      • 篩選後的筆記列表(filteredNotes):它可以根據 allNotessearchText 計算得出。
      • 筆記的總數量:它可以從 allNotes.length 推導出來。
  4. 確定狀態的存放位置(Identify Where Your State Should Live): 這一步通常是新手最困惑的地方。對於每一個狀態,需要決定哪個元件「擁有」這個狀態,即這個狀態應該定義在哪个元件的 state 中。React 是單向資料流,資料從父元件流向子元件。原則是:

    • 共同父層原則:找出所有需要根據這個狀態來渲染的元件。然後,找到它們在元件樹中的共同父層元件。這個狀態通常就應該放在這個共同父層元件中。
    • 狀態提升(Lifting State Up):如果多個子元件需要共享或修改同一個狀態,應該將該狀態提升到它們最近的共同父元件中。
    • 就近原則:如果一個狀態只被單一元件使用,或者只有單一元件需要修改它,那麼這個狀態可以安全地存放在該元件內部。
    • 頂層容器元件:如果找不到一個明顯的元件來擁有狀態,或者狀態需要在應用程式的許多地方共享,可以建立一個新的高階容器元件專門用於管理這個狀態,並將其透過 props 向下傳遞。
    • 範例(續前)
      • allNotesselectedNoteId:可能存放在 Notebook 或更上層的 App 元件中,因為 NoteList 需要它來顯示,NoteEditor 需要它來編輯。
      • searchText:可以存放在 HeaderApp 中,因為 SearchBar 需要更新它,NoteList 可能需要根據它來篩選。
      • editorContent:最適合放在 NoteEditor 內部,因為它主要與編輯器的內容相關。
  5. 新增反向資料流(Add Inverse Data Flow): 至今為止,資料都是透過 props 由上而下流動。當子元件需要更新父元件的狀態時(例如,使用者在 SearchBar 中輸入文字,需要更新父元件的 searchText 狀態),這就是所謂的「反向資料流」。實現方式是:父元件將一個回呼函式(callback function)作為 prop 傳遞給子元件。子元件在適當的時機(如 onChange 事件、按鈕點擊事件)呼叫這個回呼函式,並可以將新的狀態值作為參數傳回,從而通知父元件更新其狀態。

    • 範例(續前)
      • Notebook 元件可以傳遞一個 onNoteSelect(noteId) 函式給 NoteListNoteList 再傳給 NoteItem。當某個 NoteItem 被點擊時,它呼叫 onNoteSelect 並傳入自己的 ID,Notebook 接收到後更新 selectedNoteId
      • Header 元件可以傳遞一個 onSearchTextChange(newText) 函式給 SearchBar。當 SearchBar 的輸入框內容改變時,它呼叫 onSearchTextChangeHeader 隨之更新 searchText

這種思考方式鼓勵開發者建立模組化、可組合且易於維護的 UI。每個元件都有其明確的職責,使得應用程式的整體結構更加清晰。

14.2 單向資料流 (One-way Data Flow)

React 強調單向資料流,這意味著資料在應用程式中主要沿著一個明確且可預測的方向流動:從父元件流向子元件。這與一些採用雙向資料繫結的框架(如早期的 AngularJS)形成對比。

  • 狀態(State):是元件私有的,由元件自身管理,通常用於處理互動性和隨時間變化的資料。當元件的 state 透過 setState(類別元件)或 useState 的更新函式(函式元件)改變時,React 會排程一次重新渲染,更新該元件及其子元件。
  • 屬性(Props):是從父元件傳遞給子元件的資料。Props 對於接收它的子元件來說是唯讀的 (read-only)。子元件不應該直接修改接收到的 props。想像 props 就像函式的參數一樣,你不應該在函式內部去修改傳入的參數本身。

運作機制比喻: 想像一個瀑布。水(資料)只能從高處(父元件)流向低處(子元件)。每個水池(元件)可以有自己的小噴泉(內部 state),但它不能讓水逆流回到上一個水池。如果低處的水池需要通知高處的水池某件事情(例如水滿了),它需要透過一個預先設置好的信號系統(回呼函式 props)來傳達。

單向資料流的好處

  • 可預測性 (Predictability):資料流動方向單一且清晰,使得追蹤資料的來源、變化以及理解應用程式的行為變得更加容易。當 UI 出現非預期行為時,你可以更容易地順著資料流找到問題的根源,而不是在複雜的雙向依賴中迷失方向。
  • 易於偵錯 (Easier Debugging):由於資料變更的來源相對集中(通常是擁有 state 的元件或外部事件),偵錯時的追蹤路徑更短。React Developer Tools 也能很好地展示 props 和 state 的變化。
  • 效能優化 (Performance Optimization):React 的重新渲染機制依賴於 state 和 props 的變化。單向資料流使得 React 更容易判斷哪些元件真正需要更新,從而避免不必要的 DOM 操作。結合不可變資料結構,可以更高效地進行變更檢測(shouldComponentUpdateReact.memo)。
  • 鬆耦合 (Loose Coupling):元件之間的依賴關係更明確。子元件不直接依賴父元件的內部實現細節,只需要關心傳入的 props 和需要呼叫的回呼函式。這使得元件更容易被獨立測試和重用。

不可變性 (Immutability) 的角色: 雖然 React 本身不強制要求 props 和 state 是不可變的,但實踐中強烈建議使用不可變的方式來更新它們。這意味著當你需要修改一個物件或陣列類型的 state 或 prop 時,你應該建立一個新的物件或陣列,而不是直接修改原始的。

  • 為何重要:React 預設使用淺比較 (shallow comparison) 來判斷 state 或 props 是否發生變化。如果直接修改物件內部屬性,其引用保持不變,React 可能無法偵測到變化,導致 UI 不更新。建立新物件/陣列可以確保引用改變,觸發正確的重新渲染。
  • 實踐:使用如展開運算子 (...)、陣列的 map, filter, concat 等方法,或引入 ImmerImmutable.js 等庫來輔助處理不可變資料。

總之,單向資料流是 React 架構的基石之一,它帶來了更高的程式碼清晰度和可維護性。

14.3 保持元件小而專注

遵循單一職責原則(Single Responsibility Principle, SRP)是 React 開發中的一個黃金準則。這意味著每個元件應該只做好一件事,並且做得好。一個元件應該只有一個改變的理由。

  • 更小的元件通常意味著

    • 更高的可重用性 (Increased Reusability):一個只負責特定 UI 片段或單一功能的元件(例如,一個自訂按鈕 CustomButton,一個日期選擇器 DatePicker)更容易在應用程式的不同部分被重複使用,甚至在不同專案間共享。
    • 更好的可測試性 (Improved Testability):測試一個功能單一、輸入輸出明確的小元件,遠比測試一個包含多種邏輯、管理多個狀態的龐大元件要簡單和可靠。你可以針對其單一職責編寫精確的單元測試。
    • 更容易理解和維護 (Enhanced Readability & Maintainability):當一個元件的程式碼行數較少,邏輯集中時,開發者(包括幾個月後的你自己)更容易快速理解其功能和內部工作方式。修改或擴展功能時,影響範圍也更可控,降低了引入新 bug 的風險。
    • 更清晰的邊界與契約 (Clearer Boundaries & Contracts):小元件的 props 通常更少且更明確,這形成了清晰的 API 契約。團隊成員可以更容易地協同工作,因為每個元件的職責和介面都定義良好。
  • 如何判斷元件是否過大或職責不清?問自己以下問題

    • 渲染邏輯過於複雜render 方法(或函式元件的返回 JSX)是否非常冗長,包含了大量條件渲染、巢狀結構,或者混合了多個不相關的 UI 區塊?
    • 狀態管理混亂:元件是否透過 useStatethis.state 管理了過多不直接相關的狀態片段?這些狀態是否可以被歸類到不同的子元件中?
    • 混合多種關注點:元件是否同時處理資料獲取、資料轉換/格式化、複雜的業務邏輯、使用者事件處理以及 UI 呈現?例如,一個元件既負責從 API 拉取使用者列表,又負責顯示列表,還處理列表項的點擊、排序和篩選邏輯。
    • 難以命名:如果你很難給一個元件起一個簡潔明瞭的名字來描述它的功能,那它可能做了太多事情。
    • 頻繁修改:如果對應用程式某個小功能的修改,總是需要動到這個元件,那可能表示它的職責劃分有問題。
  • 重構範例:將臃腫的 UserProfile 元件拆分 假設有一個 UserProfile 元件,它顯示使用者的頭像、姓名、簡介、好友列表和最近活動。

    // 最初臃腫的 UserProfile.js
    function UserProfile({ userId }) {
      const [user, setUser] = useState(null);
      const [friends, setFriends] = useState([]);
      const [activities, setActivities] = useState([]);
    
      useEffect(() => {
        // 模擬 API 呼叫
        fetchUserProfile(userId).then(data => setUser(data.profile));
        fetchUserFriends(userId).then(data => setFriends(data.friends));
        fetchUserActivities(userId).then(data => setActivities(data.activities));
      }, [userId]);
    
      if (!user) return <p>Loading profile...</p>;
    
      return (
        <div className="user-profile">
          <img src={user.avatarUrl} alt={user.name} />
          <h2>{user.name}</h2>
          <p>{user.bio}</p>
    
          <div className="friends-section">
            <h3>Friends</h3>
            {friends.length > 0 ? (
              <ul>
                {friends.map(friend => <li key={friend.id}>{friend.name}</li>)}
              </ul>
            ) : (
              <p>No friends to show.</p>
            )}
          </div>
    
          <div className="activity-section">
            <h3>Recent Activities</h3>
            {activities.length > 0 ? (
              <ul>
                {activities.map(activity => <li key={activity.id}>{activity.description}</li>)}
              </ul>
            ) : (
              <p>No recent activities.</p>
            )}
          </div>
        </div>
      );
    }
    

    可以拆分成

    • UserAvatar({ avatarUrl, name })
    • UserInfo({ name, bio })
    • FriendList({ userId }) (內部處理自己的資料獲取和渲染邏輯)
    • ActivityFeed({ userId }) (內部處理自己的資料獲取和渲染邏輯)
    // 重構後的 UserProfile.js
    function UserProfile({ userId }) {
      const [user, setUser] = useState(null);
    
      useEffect(() => {
        fetchUserProfile(userId).then(data => setUser(data.profile));
      }, [userId]);
    
      if (!user) return <p>Loading profile...</p>;
    
      return (
        <div className="user-profile">
          <UserAvatar avatarUrl={user.avatarUrl} name={user.name} />
          <UserInfo name={user.name} bio={user.bio} />
          <hr />
          <FriendList userId={userId} />
          <hr />
          <ActivityFeed userId={userId} />
        </div>
      );
    }
    // ... FriendList.js 和 ActivityFeed.js 會有各自的 useEffect 和 state
    

    在這個重構後的版本中,UserProfile 只關心獲取和顯示核心使用者資訊,而好友列表和活動資訊的獲取與顯示則委託給了更專注的子元件。每個子元件都可以獨立開發、測試和維護。

雖然拆分元件會增加元件的總數量和一些 props 的傳遞,但從長遠來看,這種細粒度的元件化策略會帶來更健康、更可持續、更易於團隊協作的程式碼庫。

14.4 React 最佳實踐

除了上述核心哲學外,結合 learning-react-go 指南中的實踐,以下是一些在 React 開發中推薦的最佳實踐,旨在提升程式碼品質、開發效率和應用程式效能:

  1. 使用現代 JavaScript/TypeScript

    • TypeScript 優先:如 learning-react-go 中強調,使用 TypeScript (例如 React 18.3.1 + TypeScript) 進行元件開發。TypeScript 提供的靜態型別檢查能在編譯時期捕捉大量潛在錯誤,減少執行時 bug,並為大型專案提供更好的程式碼組織和重構能力。其豐富的型別資訊也使得程式碼更易讀,IDE 的自動完成和提示功能更強大。
      • 為何如此:減少執行時錯誤,提升程式碼可讀性和可維護性,改善開發者體驗。
      • learning-react-go 範例:指南中的 web/src/types/memo.ts 展示了如何定義清晰的型別介面 Memo, MemoCreate, MemoUpdate
    • 遵循嚴格型別檢查:在 tsconfig.json 中啟用如 strict: true 等選項,以充分利用 TypeScript 型別系統的優勢,避免常見的型別陷阱。
      • 為何如此:最大化型別安全的好處,讓型別系統真正成為你的助手。
  2. 元件設計與狀態管理

    • 組合優於繼承:如第 13 章詳述,React 更推崇使用元件組合而非類別繼承來實現 UI 的重用和邏輯的劃分。這使得元件之間的關係更明確,耦合度更低。
      • 為何如此:更靈活,避免深層繼承的複雜性,props 傳遞更明確。
    • 選擇合適的狀態管理方案
      • 元件內部狀態 (useState, useReducer):對於僅限於單一元件或少數緊密相關子元件的狀態,React 內建的 Hooks 是首選,它們簡單直接。
      • Context API:適用於需要在元件樹中跨多層級共享的「全域性」資料,如主題配置、使用者認證狀態等,避免 props drilling。但需注意 Context 引起的重新渲染問題。
      • 外部狀態管理庫 (MobX, Zustand, Redux):對於複雜的應用程式狀態、跨多個不直接相關元件共享的狀態,或者需要更精細的狀態更新控制和開發工具支援時,如 learning-react-go 中提到的 MobX (v2 stores) 或 Zustand (v1 stores) 是很好的選擇。它們提供了更結構化的方式來管理和響應狀態變化。
        • 為何如此:根據狀態的複雜度和作用域選擇工具,避免過度設計或狀態管理混亂。
        • learning-react-go 範例web/src/store/memo.ts (MobX) 和 web/src/store/global.ts (Zustand) 展示了兩種庫的實際用法。
    • 避免不必要的 Props Drilling:當資料需要透過多層中間元件才能到達目標子元件時,稱為 props drilling。這會使中間元件接收與自身無關的 props,降低可讀性和維護性。應考慮使用 Context API 或狀態管理庫來解決。
      • 為何如此:保持元件介面的清潔,減少不必要的耦合。
  3. 程式碼品質與風格

    • 使用 ESLint 和 Prettier:ESLint 用於靜態程式碼分析,發現潛在錯誤和不規範寫法;Prettier 用於自動化程式碼格式化。兩者結合可以確保整個專案的程式碼風格一致,減少無謂的程式碼風格爭論,提高可讀性。
      • 為何如此:提升程式碼品質,統一團隊風格,自動化繁瑣工作。
    • 編寫清晰的註解和文件:對於複雜的業務邏輯、演算法、公開的元件 API 或不直觀的程式碼片段,編寫必要的註解。對於可重用元件庫或核心模組,提供 Markdown 格式的文件是個好習慣。
      • 為何如此:方便他人和未來的自己理解程式碼。
    • 遵循專案的程式碼風格與命名規範:例如,元件名使用大駝峰 (PascalCase),變數和函式名使用小駝峰 (camelCase),常數使用全大寫蛇形 (UPPER_SNAKE_CASE)。保持一致性。
      • 為何如此:提高程式碼一致性和可讀性。
  4. 效能優化

    • 合理使用 React.memouseMemouseCallback
      • React.memo:用於對函式元件進行記憶化,當其 props 未發生變化時,跳過重新渲染。適用於渲染成本高且 props 經常不變的元件。
      • useMemo:用於記憶化計算結果。如果某個計算非常耗時,且其依賴項不經常改變,useMemo 可以緩存結果,避免重複計算。
      • useCallback:用於記憶化回呼函式。當將回呼函式作為 prop 傳遞給子元件,且子元件依賴這個函式的引用穩定性(例如,子元件使用了 React.memo 或在 useEffect 的依賴陣列中使用了該函式)時,useCallback 可以防止不必要的子元件重新渲染。
      • 為何如此:避免不必要的計算和渲染,提升應用程式回應速度。
      • learning-react-go 範例web/src/components/MemoContent/index.tsx 中的 useMemo(() => content || '', [content]) 是一個簡單的 useMemo 應用。
    • 列表渲染時使用穩定且唯一的 key prop:當渲染一個元素列表時,為每個列表項提供一個在其兄弟節點中唯一的、穩定的 key prop。這有助於 React 高效地識別哪些項目發生了變更、新增或刪除,從而最小化 DOM 操作。不要使用陣列索引作為 key,除非列表是靜態且永不重新排序或篩選的。
      • 為何如此:提升列表渲染和更新的效率,避免潛在的狀態混淆問題。
    • 虛擬化長列表 (Windowing/Virtualization):對於可能包含成百上千項的長列表,一次性渲染所有列表項會導致嚴重的效能問題。應使用如 react-windowreact-virtualized 等庫,它們只渲染視窗可見區域內的列表項。
      • 為何如此:大幅提升長列表的渲染效能和使用者體驗。
    • Code Splitting (程式碼分割):使用 React.lazySuspense 按需載入路由對應的元件或其他大型元件。這樣可以減小初始載入的 JavaScript 包體積,加快首頁呈現速度。
      • 為何如此:優化初始載入時間,提升使用者感知效能。
  5. 測試

    • 編寫單元測試、整合測試和端對端測試
      • 單元測試:針對最小的可測試單元(如單個函式、元件)進行測試。使用 Jest、React Testing Library (RTL) 等工具,RTL 鼓勵從使用者角度測試元件行為。
      • 整合測試:測試多個元件協同工作的場景。
      • 端對端測試 (E2E):使用 Cypress、Playwright 等工具模擬真實使用者操作流程進行測試。
      • 為何如此:確保程式碼品質,提早發現 bug,增加重構信心,保障應用程式穩定性。
      • learning-react-go 提及:編寫單元測試和整合測試是程式碼品質的一部分。
  6. 開發工具與流程

    • 使用 Vite 等現代建置工具:如 learning-react-go 指南中推薦,Vite 提供了基於原生 ES Modules 的極快冷啟動速度和即時熱模組替換(HMR),極大提升開發體驗。
      • 為何如此:提高開發效率,縮短等待時間。
    • 利用 React Developer Tools:這是瀏覽器擴充功能,允許你檢查 React 元件層級、檢視和編輯元件的 props 和 state,分析元件渲染效能(Profiler)。
      • 為何如此:強大的偵錯和效能分析工具。
  7. 使用者體驗與可訪問性 (Accessibility, a11y)

    • 重視錯誤處理和使用者回饋:提供清晰、友好的錯誤提示。對於耗時操作,給予適當的載入指示(如 learning-react-goButton 元件的 loading 狀態)。妥善處理 API 請求失敗等異常情況,避免應用程式崩潰或卡死。
      • 為何如此:提升使用者滿意度和應用程式的健壯性。
    • 確保可訪問性 (a11y):遵循 WAI-ARIA (Web Accessibility Initiative – Accessible Rich Internet Applications) 標準,使用語義化的 HTML 標籤(例如,用 <button> 而不是用 <div> 加點擊事件來做按鈕),確保表單元素有標籤,圖片有 alt 文字,應用程式可以透過鍵盤完全操作,顏色對比度足夠等。使用 eslint-plugin-jsx-a11y 等工具輔助檢查。
      • 為何如此:讓應用程式對所有使用者(包括殘障人士和使用輔助技術的使用者)都是可用和友好的。
  8. 路由管理

    • 使用成熟的路由庫:如 learning-react-go 中使用的 React Router (7.3.0 版本) 進行前端路由管理。它支援宣告式路由、巢狀路由、路由參數、導覽守衛等功能,是社群標準。
      • 為何如此:提供結構化的方式管理應用程式的頁面導覽和 URL。
      • learning-react-go 範例web/src/router/index.tsx 展示了路由配置。
  9. UI 框架與樣式

    • 選擇合適的 UI 框架/元件庫:如 learning-react-go 中提到的 Material-UI (MUI) Joy UI 結合 Emotion,可以提供一套設計一致、功能豐富的預製元件,加速 UI 開發。
      • 為何如此:節省開發時間,保證 UI 一致性和品質。
      • learning-react-go 範例web/src/components/Common/Button.tsx 使用了 Joy UI 的按鈕。
    • 使用 TailwindCSS 等工具化 CSS 方案:TailwindCSS (如 learning-react-go 中的 3.4.17 版本) 是一個實用程式優先的 CSS 框架,它提供了大量原子化的 CSS 類,讓你直接在 HTML (JSX) 中快速建構自訂設計,而無需編寫太多自訂 CSS。
      • 為何如此:快速原型製作,高度可自訂,避免 CSS 檔案膨脹和命名衝突。
    • 一致的圖示庫:如 learning-react-go 使用的 Lucide React,提供風格統一且豐富的 SVG 圖示資源。
      • 為何如此:提升視覺一致性和專業度。
  10. API 互動

    • 抽象 API 呼叫:將 API 請求邏輯封裝在專用的服務或 Hooks 中,而不是直接散落在元件程式碼裡。這使得 API 邏輯更易於管理、測試和替換(例如,從 REST 切換到 GraphQL)。
    • 處理載入和錯誤狀態:對於任何涉及網路請求的操作,都要明確處理載入中(loading)、成功(success)和錯誤(error)三種狀態,並在 UI 上給予使用者適當的回饋。

遵循這些哲學和最佳實踐,並結合專案的具體需求和團隊的實際情況,可以幫助開發者和團隊建構出高效、可維護、可擴展且使用者體驗良好的 React 應用程式。

results matching ""

    No results matching ""