重新渲染到底由什麼觸發?(答案只有三種來源)
為什麼「props 變了所以重新渲染」是不精確的說法?
在協調過程中扮演什麼角色?為什麼用陣列索引當 key 會出 bug?
、、 各自的定位, 以及為什麼「全部都包起來」是錯的?
「這個元件為什麼一直重新渲染」大概是前端工作裡最常見的效能疑問, 而多數人是靠著口耳相傳的規則在猜。這一章把規則追到源頭: 觸發渲染的來源其實非常少,把它們列乾淨,剩下的行為都能推導出來。
觸發重新渲染的三個來源
除了初次掛載,一個元件重新渲染,原因只可能是下列三者之一:
自己的狀態更新了:呼叫 (或 ), 且新值與舊值經 比較不相等。
父元件重新渲染了:父元件重新執行時,會重新建立子元件的元素, 預設情況下子元件就會跟著重新渲染——跟 props 有沒有變無關。
訂閱的 context 更新了:元件用 讀取的 Provider, 其 換了新引用。
這句話因果顛倒了。props 是父元件渲染時傳進來的引數—— props 會變,正是因為父元件重新渲染了; 而只要父元件重新渲染,子元件預設就會重新渲染, 即使 props 一個字都沒變。React 預設根本不比較 props。 「props 沒變就跳過渲染」是 提供的選配行為,不是預設值。 把這個因果關係弄對,後面 memo 系列工具的定位才會清楚。
所以預設行為是「渲染會沿著樹往下傳染」:根元件一動,整棵子樹跟著重算。 聽起來很浪費?回想第 1 章:渲染只是重新執行函式、產出新的描述物件, 真正貴的 DOM 更新已經被 commit 階段最小化了。 對絕大多數應用程式,這個「浪費」根本量測不出來—— 這是 React 刻意的取捨:預設選擇簡單正確(全部重算),把優化留給真正需要的地方。
一次渲染是一張快照
進入優化工具之前,必須先建立一個常被跳過、卻解釋了無數怪異行為的觀念: 每次渲染擁有自己固定不變的 props、狀態與函式。
元件函式每執行一次,函式裡的所有東西——變數、事件處理器、傳給 hook 的閉包—— 都是「那一次執行」的產物,捕捉的是那一刻的狀態值。看這個經典例子:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// 點一下之後 count 是多少?
}
return <button onClick={handleClick}>{count}</button>;
}
答案是 1,不是 3。在這次渲染裡 就是常數 , 三行都是 ——你排進佇列的是三次「把狀態設成 1」。 狀態不是「即時變數」,是渲染之間的快照; 也不會就地改變 ,它只是「預約下一次渲染」。
如果意圖是「在前一個結果上繼續算」,要用函數式更新, 把「如何從舊值算新值」交給 React:
setCount((c) => c + 1); // 收到的 c 是佇列中最新的值
setCount((c) => c + 1);
setCount((c) => c + 1); // 結果是 3
另一個推論:連續呼叫多次 並不會渲染多次。 React 會批次處理(batching)——把同一個事件(React 18 之後包含 、promise 回呼等所有地方)裡的狀態更新收集起來,只安排一次重新渲染。 所以「盡量把多個 set 合併成一個以減少渲染」這種手工優化,多數時候是不必要的。
還有一個守門機制: 的新值若與目前值 相等, React 會直接跳過這次更新,不重新渲染。 這再次呼應第 1 章——mutate 之後 回同一個物件,就是在這裡被擋掉的。
協調與 key:React 怎麼認出「同一個」元件
渲染產出新的元素樹之後,React 要決定哪些舊節點可以沿用。規則很簡單:
同一個位置、同一種型別(同一個標籤名或同一個元件函式)→ 視為同一個實例:保留它的狀態與 DOM,只更新變動的屬性。
型別不同 → 舊子樹整棵卸載(狀態全部銷毀),新子樹重新掛載。
「位置」這個詞在列表裡會出問題:列表重新排序或插入刪除時, 「第 2 個位置」前後指的可能不是同一筆資料。這就是 的工作—— key 讓 React 用「身分」而不是「位置」來配對新舊節點。
用陣列索引當 key,等於把「位置」再包裝一次,配對就會出錯:
// 反模式:index 當 key,且列表項有自己的狀態(輸入框)
{todos.map((todo, i) => (
<TodoRow key={i} todo={todo} /> // TodoRow 內含 <input>
))}
假設畫面上有三列,你在第一列的輸入框打了字(這是 的內部狀態), 然後刪除第一筆資料。新列表裡原本的第二筆變成 —— React 一比對:「 的 還在」, 於是保留了第一列的元件實例(連同你打的字),只把 props 換成第二筆資料。 結果:輸入的文字「黏」在錯誤的資料列上。 改用穩定的識別(),React 才能正確地銷毀第一列、保留其餘。
用資料的穩定識別碼(資料庫 id、uuid)。
陣列索引只在「列表永遠不重排、不插刪、項目無狀態」時勉強可用——但需求會變,預設就用 id 最安全。
不要用 當 key:每次渲染都是新 key,等於強迫整列卸載重建。
反過來利用:刻意換 key 可以強制重置元件狀態, 例如 ,切換使用者時讓表單整個重來, 比在 effect 裡手動清空欄位乾淨得多。
「型別不同就整棵重建」還解釋了一個真實世界的 bug 模式:
// Bug:在元件內定義元件
function Parent() {
const [text, setText] = useState('');
// 每次 Parent 渲染,Child 都是「新建立的函式」
function Child() {
return <input />; // 使用者打的字會一直消失!
}
return (
<>
<input value={text} onChange={(e) => setText(e.target.value)} />
<Child />
</>
);
}
每次 渲染都會建立一個新的 函式。 對協調演算法而言,新舊 是兩個不同的元件型別, 於是每次渲染都把 整棵卸載重建,內部狀態(焦點、輸入值)全部蒸發。 規則:元件永遠定義在模組最上層,需要參數就用 props 傳。
React.memo:把「傳染」擋在邊界上
回到觸發來源第 2 條:父元件渲染,子元件預設跟著渲染。 當某個子樹確實很重(大表格、複雜圖表),而且它的 props 常常沒變, 就可以用 在它門口設一道檢查哨:
const UserTable = React.memo(function UserTable({ users, onSelect }: Props) {
// 只有 users 或 onSelect(淺比較)變了才重新渲染
return /* 幾百列的表格 */;
});
的規則:父元件渲染時,對新舊 props 逐一做 淺比較, 全部相等就跳過這次渲染,直接沿用上次的結果。
注意「 淺比較」這幾個字——它就是 在實務上「失效」的全部原因:
function Dashboard() {
const [tab, setTab] = useState('overview');
return (
<UserTable
users={allUsers.filter((u) => u.active)} // 每次渲染都是新陣列!
onSelect={(id) => navigate(`/users/${id}`)} // 每次渲染都是新函式!
/>
);
}
每次因為 改變而渲染時, 產生新陣列、箭頭函式是新函式——兩個 props 的引用都變了, 的比較永遠不會通過,檢查哨形同虛設。 這就輪到另外兩個工具出場了。
useMemo 與 useCallback:讓引用跨渲染存活
這兩個 hook 常被籠統地理解成「效能優化」,但它們真正做的事非常具體: 在依賴不變的前提下,讓「同一個值/函式的引用」跨越多次渲染。
function Dashboard() {
const [tab, setTab] = useState('overview');
// 依賴 allUsers 沒變 → 回傳「上一次的那個陣列」(同一個引用)
const activeUsers = useMemo(
() => allUsers.filter((u) => u.active),
[allUsers]
);
// 依賴為空 → 永遠是同一個函式引用
const handleSelect = useCallback((id: string) => {
navigate(`/users/${id}`);
}, []);
return <UserTable users={activeUsers} onSelect={handleSelect} />;
}
現在 改變時, 收到的兩個 props 引用都與上次相同, 的淺比較通過,大表格不再陪跑。 順帶記住一個等式,可以少背一個 API: 完全等價於 —— 前者快取函式本身,後者快取函式的回傳值。
它們的第二個用途一樣是「穩定引用」,只是消費者換成 hook 的依賴陣列: 如果某個物件/函式要放進 的依賴, 沒有穩定引用的話,effect 每次渲染都會重跑(第 4 章會再遇到這件事)。
是檢查哨:props 沒變就不渲染。 / 是保引用:依賴沒變就給同一個引用。 後兩者常常是為了讓前者(或依賴陣列)的 比較能夠通過而存在。 單獨使用 而下游沒有任何 memo 元件或依賴陣列在消費它的穩定性, 通常是白付成本。
每個 / 都有成本:快取佔記憶體、 每次渲染要做依賴比較、程式碼變長變難讀, 而且只要一個 props 破功(例如漏掉一個 inline 物件),整組 memo 就無效。 正確的順序是:先確認有量測得到的問題(React DevTools 的 Profiler 可以錄下 每次互動觸發了哪些元件渲染、各花多少時間), 找到真正的熱點,再沿著「memo 檢查哨+穩定該元件的所有 props」佈防。 順帶一提,很多「重渲染災難」的更好解法是搬狀態: 把高頻變動的狀態往下移(colocation,第 2 章), 讓它天生只影響一小塊子樹,根本不需要 memo。
Context 與重新渲染
第 2 章提過 context 的廣播性質,現在可以講精確了: Provider 重新渲染時,若 的引用與上次不同, 所有 它的元件都會重新渲染。而最常見的失誤是:
function AuthProvider({ children }: { children: React.ReactNode }) {
const [user, setUser] = useState<User | null>(null);
// 反模式:value 是每次渲染新建的物件
// AuthProvider 因任何原因重新渲染,全部 consumer 都跟著渲染
return (
<AuthContext.Provider value={{ user, logout }}>
{children}
</AuthContext.Provider>
);
}
// 修正:讓 value 的引用只在內容真正改變時更新
const value = useMemo(() => ({ user, logout }), [user, logout]);
若 context 同時裝了「資料」和「更新函式」,另一個常見手法是拆成兩個 context ( 與 ): 只呼叫動作、不讀資料的元件訂閱後者,資料更新時就不會被波及。
重新渲染只有三個來源:自己 setState(且值不同)、父元件渲染、訂閱的 context 更新。props 改變是父元件渲染的「結果」,不是獨立的觸發源。
每次渲染是一張快照:狀態在該次渲染內是常數; 是預約下次渲染;需要接續計算用函數式更新;更新會自動批次。
協調用「位置+型別」配對節點,列表用 key 改成「身分」配對;index key 在會變動的列表是 bug 源;換 key 可刻意重置狀態;元件不可定義在元件內。
擋渲染、/ 穩引用,三者配套才有效;先量測、先考慮搬狀態,再談 memo。
context 的 要用 穩定引用,必要時把 state 與 dispatch 拆開。