第 14 章:React 哲學與最佳實踐
14.1 React 的思考方式:元件化拆分 UI
React 的核心思想是將使用者介面(UI)拆分成獨立、可重用的部分,也就是「元件」(Components)。這種方法不僅使程式碼更有組織,也提升了可維護性和開發效率。當我們思考一個 React 應用程式時,應該從元件的角度出發,遵循以下步驟:
繪製 UI 藍圖並劃分元件層級(Mockup and Component Hierarchy): 首先,與設計師合作(如果有的話)或自己勾勒出應用程式的視覺藍圖。然後,將這個藍圖上的每一個視覺區塊都圈出來,給它們命名。這些圈出來的區塊就是你的元件的候選者。思考它們之間的層級關係:哪些元件是其他元件的子嗣?
- 範例:假設我們要建置一個簡單的筆記應用程式界面。
- 最外層可能是
App
元件。 App
裡面可能有一個Header
元件(包含 Logo 和搜尋框)。Header
內的搜尋框可以是SearchBar
元件。App
裡面還有一個Notebook
元件。Notebook
包含一個NoteList
元件(顯示筆記列表)和一個NoteEditor
元件(用於撰寫或編輯筆記)。NoteList
中的每一則筆記摘要可以是NoteItem
元件。 這種由大到小、由外到內的拆分方式,有助於形成清晰的元件樹。
- 最外層可能是
- 範例:假設我們要建置一個簡單的筆記應用程式界面。
建立靜態版本(Build a Static Version): 此階段的目標是根據已定義的資料模型,將 UI 完美呈現出來,但不包含任何互動性。最好是將資料流動與 UI 渲染分開處理。你可以選擇:
- 由上而下(Top-down):從元件樹的頂層(如
App
)開始建構,逐步向下實作子元件。 - 由下而上(Bottom-up):從最基礎、最細粒度的元件(如
NoteItem
或按鈕)開始,然後將它們組合成更複雜的父元件。 對於大型專案,由下而上通常更容易測試和管理。在這個階段,大量使用props
來傳遞資料。例如,NoteList
會接收一個筆記陣列的 prop,然後遍歷這個陣列,為每個筆記渲染一個NoteItem
,並將單個筆記的資料作為 prop 傳給NoteItem
。
- 由上而下(Top-down):從元件樹的頂層(如
識別最小且完整的狀態表示(Identify the Minimal (but complete) Representation of UI State): 現在,思考應用程式中所有會隨時間改變的資料。哪些是應用程式的「狀態」(state)?關鍵在於DRY (Don't Repeat Yourself) 原則。找出構成應用程式所需狀態的最小集合,避免冗餘。任何可以從其他狀態計算出來的資料,都不應該成為獨立的狀態。
- 範例(續前):
- 原始的筆記列表 (
allNotes
)。 - 使用者在
SearchBar
中輸入的搜尋文字 (searchText
)。 - 當前在
NoteEditor
中被選中編輯的筆記 ID (selectedNoteId
)。 NoteEditor
中正在輸入的筆記內容 (editorContent
)。
- 原始的筆記列表 (
- 不應該是狀態的例子:
- 篩選後的筆記列表(
filteredNotes
):它可以根據allNotes
和searchText
計算得出。 - 筆記的總數量:它可以從
allNotes.length
推導出來。
- 篩選後的筆記列表(
- 範例(續前):
確定狀態的存放位置(Identify Where Your State Should Live): 這一步通常是新手最困惑的地方。對於每一個狀態,需要決定哪個元件「擁有」這個狀態,即這個狀態應該定義在哪个元件的
state
中。React 是單向資料流,資料從父元件流向子元件。原則是:- 共同父層原則:找出所有需要根據這個狀態來渲染的元件。然後,找到它們在元件樹中的共同父層元件。這個狀態通常就應該放在這個共同父層元件中。
- 狀態提升(Lifting State Up):如果多個子元件需要共享或修改同一個狀態,應該將該狀態提升到它們最近的共同父元件中。
- 就近原則:如果一個狀態只被單一元件使用,或者只有單一元件需要修改它,那麼這個狀態可以安全地存放在該元件內部。
- 頂層容器元件:如果找不到一個明顯的元件來擁有狀態,或者狀態需要在應用程式的許多地方共享,可以建立一個新的高階容器元件專門用於管理這個狀態,並將其透過 props 向下傳遞。
- 範例(續前):
allNotes
和selectedNoteId
:可能存放在Notebook
或更上層的App
元件中,因為NoteList
需要它來顯示,NoteEditor
需要它來編輯。searchText
:可以存放在Header
或App
中,因為SearchBar
需要更新它,NoteList
可能需要根據它來篩選。editorContent
:最適合放在NoteEditor
內部,因為它主要與編輯器的內容相關。
新增反向資料流(Add Inverse Data Flow): 至今為止,資料都是透過 props 由上而下流動。當子元件需要更新父元件的狀態時(例如,使用者在
SearchBar
中輸入文字,需要更新父元件的searchText
狀態),這就是所謂的「反向資料流」。實現方式是:父元件將一個回呼函式(callback function)作為 prop 傳遞給子元件。子元件在適當的時機(如onChange
事件、按鈕點擊事件)呼叫這個回呼函式,並可以將新的狀態值作為參數傳回,從而通知父元件更新其狀態。- 範例(續前):
Notebook
元件可以傳遞一個onNoteSelect(noteId)
函式給NoteList
,NoteList
再傳給NoteItem
。當某個NoteItem
被點擊時,它呼叫onNoteSelect
並傳入自己的 ID,Notebook
接收到後更新selectedNoteId
。Header
元件可以傳遞一個onSearchTextChange(newText)
函式給SearchBar
。當SearchBar
的輸入框內容改變時,它呼叫onSearchTextChange
,Header
隨之更新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 操作。結合不可變資料結構,可以更高效地進行變更檢測(
shouldComponentUpdate
或React.memo
)。 - 鬆耦合 (Loose Coupling):元件之間的依賴關係更明確。子元件不直接依賴父元件的內部實現細節,只需要關心傳入的 props 和需要呼叫的回呼函式。這使得元件更容易被獨立測試和重用。
不可變性 (Immutability) 的角色: 雖然 React 本身不強制要求 props 和 state 是不可變的,但實踐中強烈建議使用不可變的方式來更新它們。這意味著當你需要修改一個物件或陣列類型的 state 或 prop 時,你應該建立一個新的物件或陣列,而不是直接修改原始的。
- 為何重要:React 預設使用淺比較 (shallow comparison) 來判斷 state 或 props 是否發生變化。如果直接修改物件內部屬性,其引用保持不變,React 可能無法偵測到變化,導致 UI 不更新。建立新物件/陣列可以確保引用改變,觸發正確的重新渲染。
- 實踐:使用如展開運算子 (
...
)、陣列的map
,filter
,concat
等方法,或引入Immer
、Immutable.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 契約。團隊成員可以更容易地協同工作,因為每個元件的職責和介面都定義良好。
- 更高的可重用性 (Increased Reusability):一個只負責特定 UI 片段或單一功能的元件(例如,一個自訂按鈕
如何判斷元件是否過大或職責不清?問自己以下問題:
- 渲染邏輯過於複雜:
render
方法(或函式元件的返回 JSX)是否非常冗長,包含了大量條件渲染、巢狀結構,或者混合了多個不相關的 UI 區塊? - 狀態管理混亂:元件是否透過
useState
或this.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 開發中推薦的最佳實踐,旨在提升程式碼品質、開發效率和應用程式效能:
使用現代 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 型別系統的優勢,避免常見的型別陷阱。- 為何如此:最大化型別安全的好處,讓型別系統真正成為你的助手。
- TypeScript 優先:如
元件設計與狀態管理:
- 組合優於繼承:如第 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 或狀態管理庫來解決。
- 為何如此:保持元件介面的清潔,減少不必要的耦合。
- 組合優於繼承:如第 13 章詳述,React 更推崇使用元件組合而非類別繼承來實現 UI 的重用和邏輯的劃分。這使得元件之間的關係更明確,耦合度更低。
程式碼品質與風格:
- 使用 ESLint 和 Prettier:ESLint 用於靜態程式碼分析,發現潛在錯誤和不規範寫法;Prettier 用於自動化程式碼格式化。兩者結合可以確保整個專案的程式碼風格一致,減少無謂的程式碼風格爭論,提高可讀性。
- 為何如此:提升程式碼品質,統一團隊風格,自動化繁瑣工作。
- 編寫清晰的註解和文件:對於複雜的業務邏輯、演算法、公開的元件 API 或不直觀的程式碼片段,編寫必要的註解。對於可重用元件庫或核心模組,提供 Markdown 格式的文件是個好習慣。
- 為何如此:方便他人和未來的自己理解程式碼。
- 遵循專案的程式碼風格與命名規範:例如,元件名使用大駝峰 (PascalCase),變數和函式名使用小駝峰 (camelCase),常數使用全大寫蛇形 (UPPER_SNAKE_CASE)。保持一致性。
- 為何如此:提高程式碼一致性和可讀性。
- 使用 ESLint 和 Prettier:ESLint 用於靜態程式碼分析,發現潛在錯誤和不規範寫法;Prettier 用於自動化程式碼格式化。兩者結合可以確保整個專案的程式碼風格一致,減少無謂的程式碼風格爭論,提高可讀性。
效能優化:
- 合理使用
React.memo
、useMemo
和useCallback
: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-window
或react-virtualized
等庫,它們只渲染視窗可見區域內的列表項。- 為何如此:大幅提升長列表的渲染效能和使用者體驗。
- Code Splitting (程式碼分割):使用
React.lazy
和Suspense
按需載入路由對應的元件或其他大型元件。這樣可以減小初始載入的 JavaScript 包體積,加快首頁呈現速度。- 為何如此:優化初始載入時間,提升使用者感知效能。
- 合理使用
測試:
- 編寫單元測試、整合測試和端對端測試:
- 單元測試:針對最小的可測試單元(如單個函式、元件)進行測試。使用 Jest、React Testing Library (RTL) 等工具,RTL 鼓勵從使用者角度測試元件行為。
- 整合測試:測試多個元件協同工作的場景。
- 端對端測試 (E2E):使用 Cypress、Playwright 等工具模擬真實使用者操作流程進行測試。
- 為何如此:確保程式碼品質,提早發現 bug,增加重構信心,保障應用程式穩定性。
learning-react-go
提及:編寫單元測試和整合測試是程式碼品質的一部分。
- 編寫單元測試、整合測試和端對端測試:
開發工具與流程:
- 使用 Vite 等現代建置工具:如
learning-react-go
指南中推薦,Vite 提供了基於原生 ES Modules 的極快冷啟動速度和即時熱模組替換(HMR),極大提升開發體驗。- 為何如此:提高開發效率,縮短等待時間。
- 利用 React Developer Tools:這是瀏覽器擴充功能,允許你檢查 React 元件層級、檢視和編輯元件的 props 和 state,分析元件渲染效能(Profiler)。
- 為何如此:強大的偵錯和效能分析工具。
- 使用 Vite 等現代建置工具:如
使用者體驗與可訪問性 (Accessibility, a11y):
- 重視錯誤處理和使用者回饋:提供清晰、友好的錯誤提示。對於耗時操作,給予適當的載入指示(如
learning-react-go
中Button
元件的loading
狀態)。妥善處理 API 請求失敗等異常情況,避免應用程式崩潰或卡死。- 為何如此:提升使用者滿意度和應用程式的健壯性。
- 確保可訪問性 (a11y):遵循 WAI-ARIA (Web Accessibility Initiative – Accessible Rich Internet Applications) 標準,使用語義化的 HTML 標籤(例如,用
<button>
而不是用<div>
加點擊事件來做按鈕),確保表單元素有標籤,圖片有alt
文字,應用程式可以透過鍵盤完全操作,顏色對比度足夠等。使用eslint-plugin-jsx-a11y
等工具輔助檢查。- 為何如此:讓應用程式對所有使用者(包括殘障人士和使用輔助技術的使用者)都是可用和友好的。
- 重視錯誤處理和使用者回饋:提供清晰、友好的錯誤提示。對於耗時操作,給予適當的載入指示(如
路由管理:
- 使用成熟的路由庫:如
learning-react-go
中使用的 React Router (7.3.0 版本) 進行前端路由管理。它支援宣告式路由、巢狀路由、路由參數、導覽守衛等功能,是社群標準。- 為何如此:提供結構化的方式管理應用程式的頁面導覽和 URL。
learning-react-go
範例:web/src/router/index.tsx
展示了路由配置。
- 使用成熟的路由庫:如
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 圖示資源。- 為何如此:提升視覺一致性和專業度。
- 選擇合適的 UI 框架/元件庫:如
API 互動:
- 抽象 API 呼叫:將 API 請求邏輯封裝在專用的服務或 Hooks 中,而不是直接散落在元件程式碼裡。這使得 API 邏輯更易於管理、測試和替換(例如,從 REST 切換到 GraphQL)。
- 處理載入和錯誤狀態:對於任何涉及網路請求的操作,都要明確處理載入中(loading)、成功(success)和錯誤(error)三種狀態,並在 UI 上給予使用者適當的回饋。
遵循這些哲學和最佳實踐,並結合專案的具體需求和團隊的實際情況,可以幫助開發者和團隊建構出高效、可維護、可擴展且使用者體驗良好的 React 應用程式。