課程:JavaScript 與 React 底層原理 第 10 堂:React 核心設計哲學
33 資料流與不可變性
想像你正在開發一個簡單的待辦事項清單。你在程式碼中寫下了 todos.push({ task: '學習 React' }),並且透過 console.log 確認了陣列確實增加了一個項目。然而,當你轉頭看向螢幕時,畫面上卻依然靜止,完全沒有出現新的待辦事項。
這不是瀏覽器的 bug,也不是 React 壞掉了。這正是 React 核心設計哲學中最強調的兩個概念:**單向資料流(Unidirectional Data Flow)與不可變性(Immutability)**在發揮作用。為什麼 React 寧可讓畫面「不動」,也不願意直接偵測你對物件內容的修改?這背後的設計邏輯,將直接決定你寫出的 React 應用程式是高效流暢,還是充滿難以追蹤的 Bug。
單向資料流:UI 的可預測性
在早期的前端框架(如 Angular 1.x)中,盛行的是「雙向資料綁定(Two-way Data Binding)」。在這種模式下,UI 的變動會自動更新資料,而資料的變動也會自動更新 UI。這聽起來很方便,但當應用程式變得複雜時,會演變成一場災難:資料在多個元件之間來回彈跳,你很難追蹤到底是誰在什麼時候修改了資料。
React 選擇了一條完全不同的路:單向資料流。
1. Props 向下傳遞
在 React 中,資料流動的方向就像瀑布一樣,永遠是由父元件流向子元件。父元件透過 props 將資料交給子元件,子元件對於這些資料只有「讀取權」,而沒有「修改權」。
這種設計建立了一個清晰的層級關係:
- 父元件:資料的持有者與管理者。
- 子元件:資料的消費者,負責根據收到的
props渲染畫面。
2. 事件向上回傳
如果子元件需要改變資料(例如:點擊子元件中的刪除按鈕),該怎麼辦?既然子元件不能直接修改 props,它必須透過父元件傳遞下來的 Callback 函數 來傳達意圖。
這個過程通常被稱為「Props Down, Events Up」:
- 父元件定義一個修改狀態的函數
handleDelete。 - 父元件將
handleDelete作為 prop 傳給子元件。 - 當使用者點擊按鈕,子元件呼叫該函數。
- 控制權回到父元件,由父元件更新狀態並重新觸發渲染。
狀態提升(State Lifting)
這引出了一個常見的實作問題:如果兩個「兄弟元件」需要共享同一份資料,該放在哪裡?
根據單向資料流原則,資料不應該在兄弟之間橫向傳遞。解決方案是將狀態「提升」到它們共同的父層元件。這樣一來,父層就成了這份資料的「單一真理來源(Single Source of Truth)」,並透過 props 同時餵給兩個子元件。這確保了兩者的資料永遠同步,因為它們看的是同一份來源。
不可變性:React 偵測變更的捷徑
理解了資料如何流動後,我們必須探討一個更底層的問題:React 怎麼知道資料變了?
在上一節我們提到,React 會比對新舊 Virtual DOM 樹來決定要更新哪些真實 DOM。但在進行昂貴的樹比對之前,React 會先檢查元件的 state 或 props 是否發生了變化。
這裡有一個關鍵的設計權衡:
- 方案 A(深層比較):遞迴地檢查物件或陣列裡的每一個屬性。如果陣列有一萬筆資料,這會非常緩慢(時間複雜度 $O(N)$)。
- 方案 B(參考比較):只檢查物件在記憶體中的「位址(Reference)」是否改變。這極快,只需要一瞬間(時間複雜度 $O(1)$)。
React 選擇了方案 B。
參考比較與 Object.is
React 內部使用類似 Object.is 的機制來比較狀態。當你呼叫 setState 時,React 會問:「新的狀態物件和舊的狀態物件,在記憶體中是同一個位址嗎?」
- 如果是同一個位址,React 認為「沒事發生」,直接跳過該元件的重新渲染。
- 如果是不同的位址,React 才會開始進行 Virtual DOM 的產出與比對。
這就是為什麼「不可變性(Immutability)」在 React 中如此重要。你不能修改舊物件,你必須產生一個全新的物件。
JS 底層連結:堆疊 (Stack) 與 堆積 (Heap) 的真相
要真正理解為什麼直接修改物件行不通,我們必須回到 Topic 1 與 Topic 5 討論過的 JS 記憶體模型。
變數與參考的行為
在 JavaScript 中,基礎型別(如 Number, String, Boolean)是存放在 Stack(堆疊) 中的,它們是按值傳遞的。而物件型別(Object, Array, Function)則存放在 Heap(堆積) 中,Stack 裡只存放一個指向 Heap 位址的指標(Pointer)。
讓我們看看以下程式碼:
const [user, setUser] = useState({ name: "Aria", age: 25 });
// 錯誤的做法:直接修改(Mutation)
const updateName = () => {
user.name = "Bob"; // 修改了 Heap 中的資料內容
setUser(user); // 將同一個指標(Reference)傳回給 React
};
在上面的錯誤範例中:
user.name = "Bob"確實修改了記憶體中的內容。- 但
user變數在 Stack 中存放的記憶體位址(例如0x1234)完全沒變。 - 當
setUser(user)被呼叫時,React 檢查Object.is(舊的0x1234, 新的0x1234),結果為true。 - React 結論:狀態沒變,不需更新畫面。
這就是所謂的「影子更新」——資料變了,但 UI 卻像鬼魂一樣停留在過去。
正確做法:使用 Spread 運算子產生新物件
我們在 Topic 5 學過的 Spread 運算子 (**...**) 是實作不可變更新的神器。它會執行「淺複製(Shallow Copy)」,在 Heap 中開闢一塊全新的記憶體空間,並將舊物件的屬性複製過去。
// 正確的做法:不可變更新(Immutable Update)
const updateName = () => {
setUser({
...user, // 複製舊屬性
name: "Bob" // 覆蓋需要修改的屬性
});
// 這會產生一個全新的記憶體位址(例如 0x5678)
// React 檢查 Object.is(0x1234, 0x5678) -> false -> 觸發渲染!
};
實作對比:陣列的更新陷阱
陣列也是物件的一種,因此同樣受限於參考比較的規則。以下是開發中常見的對比:
❌ 錯誤:會破壞不可變性的方法
這類方法會直接修改原陣列(In-place mutation):
push()/pop()splice()sort()reverse()
const [items, setItems] = useState([1, 2, 3]);
const addItem = () => {
items.push(4); // ❌ 原陣列被修改,但參考不變
setItems(items); // ❌ UI 不會更新
};
✅ 正確:保持不可變性的方法
這類方法會回傳一個全新的陣列:
[...items, newItem](取代 push)items.filter(...)items.map(...)items.slice()
const addItem = () => {
setItems([...items, 4]); // ✅ 產生新陣列參考,UI 正常更新
};
const removeFirst = () => {
setItems(items.filter((_, index) => index !== 0)); // ✅ filter 回傳新陣列
};
為什麼 React 要這麼「固執」?
你可能會問:「為什麼 React 不乾脆幫我做深層比較,讓我可以用 push 就好?」
答案是為了極致的效能。在一個擁有數千個元件的大型應用程式中,如果每一次細微的點擊都要進行深層的物件掃描,React 的效能會迅速崩潰。透過強迫開發者更換「參考位址」,React 可以用極小的代價(比較兩個位址)來決定是否要執行後續的 Reconciliation(協調)流程。
不可變性原則本質上是開發者與框架之間的一種「合約」:
「如果你想讓我更新畫面,請給我一個新的物件;如果你給我舊的物件,我會假設什麼都沒發生。」
這不僅讓 React 變快,也讓程式碼更容易除錯。因為狀態的變更不再是悄悄發生的「修改」,而是顯而易見的「替換」。
回顧與連結
我們已經探討了 React 核心設計的三大支柱:
- 宣告式 UI:我們描述目標狀態,而非執行步驟。
- Virtual DOM:作為描述 UI 的輕量級物件,它是計算最小更新的中間層。
- 不可變性與參考比較:這是 React 決定「何時該重新計算」的快速開關。
React 之所以高效,是因為它巧妙地利用了 JavaScript 的底層特性(如 Reference vs Value)。當你呼叫 setState 並傳入一個新物件時,你實際上是在發送一個信號,告訴 React 的調度器:位址變了,請開始比對這部分的 Virtual DOM 樹。
接下來,我們將進入總複習,整合這堂課關於核心設計哲學的所有知識。在下一個主題(Topic 7)中,我們將深入 Reconciliation 的黑盒子,看 React 在確認「位址變了」之後,是如何利用 Diffing 演算法精準地挑出需要更新的 DOM 節點,以及 key 這個神秘的屬性在其中扮演的關鍵角色。