你是否曾經寫過一行 React 程式碼,盯著瀏覽器中的輸出,心想:「等等……它為什麼會那樣做?」
這裡有一段每個 React 開發者至少都會寫過一次的經典程式碼片段:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count);
}
return <button onClick={handleClick}>Count: {count}</button>;
}
你點擊按鈕。畫面上的按鈕文字從 0 更新成 1。但在瀏覽器主控台裡,它輸出的是:
0
不是 1。是 0。
一開始,你會以為這只是有一點點延遲。所以你又點了一次。按鈕更新成 2,但主控台輸出 1。它總是慢一步。
為什麼?
我第一次遇到這件事是在很多年前,當時有人在論壇上告訴我:「哦,setState 是非同步的。」 於是好幾個月來,我都接受了這個答案。但這個答案並不是全部真相。事實上,把 state 想成「會非同步更新的變數」,正是造成生產環境中數百個 bug 的錯誤心智模型。
今天,我們要深入底層看看。不要空話。不要課本式定義。我們要看真實的資料結構、Fiber 樹、為什麼 Hook 規則存在,以及 React 到底是怎麼把你的 JavaScript 變成螢幕上的像素。
拿起你最喜歡的咖啡,我們開始吧。
當大多數人學 React 時,大腦會建立一個非常簡單的運作模型:
setCount(1)<button>Count: 0</button> 改成 <button>Count: 1</button>
如果 React 真的是這樣運作,那它就太糟了。每次 state 更新都直接搜尋並修改瀏覽器 DOM,速度慢又昂貴。瀏覽器必須重新計算樣式、重新計算版面幾何(reflow),並重繪像素。
實際上,React 做的是完全不同的事情:
你呼叫 setState 時,React 並不會立刻更新 DOM,而是排程一個未來要執行的計算。
讓我們拆解每次元件更新時都會發生的三個不同階段。
把 React 想像成一家高級餐廳的廚房。
如果客人在料理到一半時改變主意,廚師可以把那盤半成品丟掉,客人完全不會看到。但一旦盤子上桌,那就是提交。
對應到 React,大概是這樣:
[使用者互動]
|
v
1. 觸發階段(dispatchSetState 佇列化一個更新請求)
|
v
2. 渲染階段(純 JavaScript 計算:呼叫你的元件、比對 Fiber 樹)
|
v
3. 提交階段(同步 DOM 變更:document.createElement、appendChild)
|
v
4. 瀏覽器繪製(瀏覽器將更新後的像素畫到你的螢幕上)
我們逐一來看。
當你呼叫 setCount(count + 1) 時,你的元件函式不會立刻再次執行。
相反地,React 會建立一個 Update Object,並把它放進附加在該元件內部表示上的更新佇列。React 會把這個元件標記為「dirty」,並透過 React Scheduler 排程工作。
如果你快速點三下按鈕,或是在同一個事件處理器中更新三個不同的 state 變數,React 會把它們批次處理。它不會為了三份麵包棒就把廚房跑三次,而是先把整張訂單收齊。
這是最容易讓人困惑的階段,因為名稱會誤導人。
開發者聽到「render」時,會想到畫面把像素畫出來。但在 React 裡,render 只是呼叫你的元件函式。
React 會呼叫 Counter()。你的函式從上到下執行,並回傳 JSX。
那麼 JSX 是什麼?
return <button className="btn">Click me</button>;
在底層,編譯器(Babel 或 Vite)會把這段 JSX 轉成一般的 JavaScript 函式呼叫:
return React.createElement('button', { className: 'btn' }, 'Click me');
而 React.createElement() 只會回傳一個普通、輕量的 JavaScript 物件:
{
type: 'button',
props: {
className: 'btn',
children: 'Click me'
},
key: null,
ref: null
}
這就是所謂的「虛擬 DOM 節點」。它只是一個描述 UI 應該長什麼樣子的普通 JavaScript 物件,還沒有和真實的瀏覽器 DOM 建立任何關聯。
React 會拿這個新回傳的物件樹,和前一次的物件樹比較。這個比較就叫做 Reconciliation(協調)。
React 會問:「type 有變嗎?沒有,還是 button。className 有變嗎?沒有,還是 'btn'。文字有變嗎?有,從 '0' 變成 '1'。」
React 會記下一條小小的修補指令:把按鈕文字更新成 1。
現在 React 已經知道所需變更的最小集合,於是進入提交階段。
這個階段是同步的,而且不能被中斷。React 會透過原生瀏覽器 API 直接操作真實 DOM:
domNode.textContent = '1';
只有那個變更的文字節點會被碰到,其他 DOM 樹完全不受影響。
一旦提交階段結束並且 layout effects 執行完,瀏覽器引擎就接手,將更新後的像素繪製到你的螢幕上。
你是否曾經好奇,為什麼 React 有這條既嚴格又讓人有點煩的規則:
「不要在迴圈、條件式或巢狀函式裡呼叫 Hooks。」
如果你違反這條規則,主控台就會爆出一個可怕的錯誤:
Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
為什麼會這樣?React 是根據變數名稱找你的 state 嗎?
const [name, setName] = useState("Dan");
const [age, setAge] = useState(25);
當你寫下 const [name, setName] 時,JavaScript 並不會把單字 "name" 告訴 React。變數名稱 name 完全只存在於你的函式內部。React 根本不知道你把它命名成什麼。
那 React 是怎麼知道第一個 useState 對應 name,第二個 useState 對應 age 的?
React 會把你的 Hooks 以單向鏈結串列的方式,存放在元件的 Fiber 節點上。

畫面上的每個元件,內部都會以一個 FiberNode 物件表示。在那個 Fiber 節點上,有一個叫做 memoizedState 的屬性。
當你的元件第一次渲染時,React 會建立一串 hook 物件:
FiberNode (<UserProfile />)
|
v
memoizedState ──> [ Hook 0: useState ('Dan') ]
│
▼ (next)
[ Hook 1: useEffect (fetchData) ]
│
▼ (next)
[ Hook 2: useState (25) ]
│
▼ (next)
null
在每一次後續渲染時,React 不會建立新的列表。它只是把一個內部指標 workInProgressHook 設到列表中的第一個 hook:
useState():React 讀取 Hook 0('Dan')。指標前進到 Hook 1。useEffect():React 讀取 Hook 1。指標前進到 Hook 2。useState():React 讀取 Hook 2(25)。指標前進到 null。現在看看如果你把 hook 放進 if 裡會發生什麼:
function UserProfile({ isLoggedIn }) {
const [name, setName] = useState("Dan"); // Hook 0
if (isLoggedIn) {
useEffect(() => { // Hook 1(條件式!)
fetchUserData();
}, []);
}
const [age, setAge] = useState(25); // Hook 2
return <div>{name}, {age}</div>;
}
假設第一次渲染時 isLoggedIn 是 true。React 建立了三個 hook 紀錄:[name, effect, age]。
第二次渲染時,isLoggedIn 變成 false。
useState():React 讀取 Hook 0(name)。指標前進到 Hook 1。useEffect 被跳過。useState():React 讀取 Hook 1!但 Hook 1 原本應該是 effect 物件,不是你的 age state!資料完全亂掉了。React 偵測到呼叫的 hook 數量與鏈結串列不一致,拋出錯誤,並在損壞資料之前停止執行。
這就是為什麼 hook 順序在每一次渲染中都必須 100% 穩定。
回到我們一開始的謎題:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 印出 0!
}
return <button onClick={handleClick}>{count}</button>;
}
要真正理解這件事,先不要把 count 當成會隨時間改變的變數。
改成理解這條規則:
在元件的任何一次單獨渲染中,props 和 state 都是常數。

當 React 第一次呼叫你的元件函式時,它實際上是在執行:
// Render #1
function Counter() {
const count = 0; // 在這次整個執行過程中都是固定常數!
function handleClick() {
setCount(0 + 1);
console.log(0); // 它真的就是印出 0
}
return <button onClick={handleClick}>0</button>;
}
在那次函式呼叫中,count 是 0。在那次特定執行裡,它永遠只會是 0。
當你呼叫 setCount(count + 1) 時,你不是在改變區域變數 count。你也不能去改一個 const!
你是在告訴 React:「嘿 React,等你下次重新執行我的元件時,請把 count 給我 1。」
React 會排程一次新的渲染。當那次新渲染在幾毫秒後執行時,React 又會呼叫 Counter()。但這是一次完全不同、全新的函式呼叫:
// Render #2(一次全新的函式執行)
function Counter() {
const count = 1; // 在這次執行中是固定常數!
function handleClick() {
setCount(1 + 1);
console.log(1);
}
return <button onClick={handleClick}>1</button>;
}
每一次渲染都有自己的 count、自己的事件處理器,以及自己的區域作用域。這就是 JavaScript closure 的力量。
一旦你理解了快照,你就會立刻看懂這類 bug:
function Chat() {
const [message, setMessage] = useState("");
function handleSend() {
setTimeout(() => {
alert("Sent message: " + message);
}, 3000);
}
return (
<div>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSend}>3 秒後送出</button>
</div>
);
}
試試看:
"Hello"。"Goodbye"。alert 會顯示什麼?
它會顯示:Sent message: Hello。
為什麼?因為 handleSend 函式是在 message 為 "Hello" 的那次渲染中建立的。setTimeout 的 callback 關閉在那個快照上。雖然畫面上的輸入已經更新了,但 callback 持有的是你點擊按鈕那一刻的狀態照片。
如果你真的需要在非同步 callback 裡讀取目前即時值,而不想等下一次渲染,這正是 useRef 的用途:
const messageRef = useRef(message);
messageRef.current = message; // 永遠指向最新值
如果你問五個開發者 React 元件為什麼會重新渲染,至少會有三個人說:
「元件在 props 改變時會重新渲染。」
這是前端開發中最常見的迷思之一。
我們來測試一下:
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ExpensiveChild />
</div>
);
}
function ExpensiveChild() {
console.log("ExpensiveChild re-rendered!");
return <p>I take 50ms to render.</p>;
}
注意 <ExpensiveChild /> 完全沒有 props。它沒有 state。傳給它的任何東西都沒有改變。
點擊 Parent 裡的「Increment」按鈕。
ExpensiveChild 會重新渲染嗎?
會。每一次都會。

React 渲染的真正規則是:
一個元件會重新渲染,當且僅當:
useState 或 useReducer)useContext)預設情況下,只要父元件重新渲染,React 就會遞迴地重新渲染它所有的子元件、孫元件與後代元件,不管它們的 props 有沒有變。
為什麼 React 要這樣做?因為在 95% 的 Web 應用中,呼叫一次 JavaScript 函式、回傳幾個虛擬 DOM 物件,只需要 0.01 毫秒。React 會假設渲染是便宜且安全的。
初學者看到子元件重新渲染時,常常會驚慌,立刻把每個函式都包上 useCallback,每個元件都包上 React.memo。
在這之前,其實有一個更乾淨、內建的方法,叫做 元件組合(Component Composition)。
看看這個重構:
// 1. 把 state 移到自己的小包裝元件中
function CounterContainer({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment: {count}</button>
{children}
</div>
);
}
// 2. 把 ExpensiveChild 當成 children prop 傳入
function App() {
return (
<CounterContainer>
<ExpensiveChild />
</CounterContainer>
);
}
當你點擊 CounterContainer 裡的 increment 按鈕時,CounterContainer 會重新渲染。
但 ExpensiveChild 不會重新渲染!
為什麼?因為 <ExpensiveChild /> 是在 App 裡建立的,不是在 CounterContainer 裡建立的。對 CounterContainer 而言,children 只是既有的 prop 物件,而且它的參考沒有改變(prevProps.children === nextProps.children)。React 看到完全相同的物件參考,就會跳過整個子樹的渲染!
沒有 React.memo。沒有 useCallback。只有乾淨的元件架構。
你是否曾經在 React 中渲染一個輸入框列表,刪掉第一個專案後,卻發現輸入內容跑到錯誤的那一列去了?
function TodoList() {
const [items, setItems] = useState(['Buy milk', 'Walk dog', 'Read book']);
function removeFirst() {
setItems(items.slice(1));
}
return (
<div>
<button onClick={removeFirst}>Delete First</button>
<ul>
{items.map((item, index) => (
// 壞示範:用 index 當 key
<li key={index}>
<input defaultValue={item} />
</li>
))}
</ul>
</div>
);
}
你點擊「Delete First」。
你預期「Buy milk」會消失,第一個輸入框會顯示「Walk dog」。
結果,輸入框還是顯示「Buy milk」,反而第三個輸入框消失了!

為什麼會這樣?
React 的 diff 演算法會根據兩件事比較子元素:元素類型與Key。
當你把陣列索引當作 key 時:
第 1 次渲染:
0 -> Buy milk1 -> Walk dog2 -> Read book第 2 次渲染(刪掉 Buy milk 後):
0 -> Walk dog1 -> Read bookReact 會把舊的 key 0 和新的 key 0 做比較。兩者都是 key 0。兩者都是 <li><input /></li>。
React 會說:「太好了!Key 0 是同一個元素。我會保留現有的 DOM 節點及其內部狀態。」
因為 <input /> 有瀏覽器 DOM 的本地狀態(你輸入的文字),React 會保留舊的輸入框,不去更新它。接著 React 會看 Key 2。Key 2 在新列表中不見了,所以 React 會刪掉第三個 DOM 節點!
Key 不只是為了消除瀏覽器主控台裡那個黃色 React 警告而存在。
Key 是元件在不同渲染之間的持久身分辨識。
當你給元素一個穩定且唯一的 key(例如 key={item.id}):
要真正鞏固對 React 的心智模型,最好的方式就是看看它的核心概念其實有多簡單。
現在打開你瀏覽器的開發者主控台(F12),貼上這段程式碼並按 Enter:
// 用 25 行程式碼描述 React state 引擎的心智模型
const MiniReact = (() => {
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const hookIndex = currentHook;
hooks[hookIndex] = hooks[hookIndex] !== undefined ? hooks[hookIndex] : initialValue;
const setState = (newValue) => {
hooks[hookIndex] = newValue;
render(); // 觸發重新渲染
};
currentHook++;
return [hooks[hookIndex], setState];
}
function render(Component) {
if (Component) MiniReact.activeComponent = Component;
currentHook = 0; // 每次渲染前重設指標
const output = MiniReact.activeComponent();
console.log("Rendered UI:", output);
return output;
}
return { useState, render };
})();
// 拿一個元件來試試看!
function MyComponent() {
const [name, setName] = MiniReact.useState("Alex");
const [count, setCount] = MiniReact.useState(0);
return {
text: `Hello ${name}, click count is ${count}`,
click: () => setCount(count + 1),
changeName: (n) => setName(n)
};
}
// 初始渲染
let app = MiniReact.render(MyComponent);
// 模擬點擊按鈕
app.click();
// 模擬變更名稱
app.changeName("Sarah");
注意這段小小程式碼中發生了什麼:
hooks 是一個簡單的陣列,用來儲存元件外部的值。currentHook 是一個索引,每次渲染前會重設為 0。useState 時,它會讀取 hooks[currentHook] 並將索引加一。setState 會更新陣列中的專案,並觸發 render()。真正的 React 會在 Fiber 節點上使用鏈結串列、優先序佇列和並行排程,但其基本架構和這 25 行程式碼是相同的。
沒有魔法。只有陣列、指標,以及函式呼叫函式。
以下是可以收藏的快速參考表:
| 問題 | 常見迷思 | React 實際做法 |
|---|---|---|
setState 做了什麼? |
直接更新 DOM 變數 | 把更新加入 Fiber 佇列並排程一次渲染 |
為什麼 console.log 會舊? |
state 更新很慢/是非同步的 | state 在那次渲染的 closure 中是固定快照 |
| 為什麼 Hook 不能有條件地呼叫? | React 透過變數名稱找 Hook | Hooks 以有順序的鏈結串列儲存,跳過一個就會破壞所有索引 |
| 子元件什麼時候會重新渲染? | 只有 props 改變時才會 | 只要父元件重新渲染就會(除非 memo 化或作為 children 傳入) |
| JSX 會回傳什麼? | 真實的 HTML DOM 元素 | 一個普通 JavaScript 物件(React.createElement) |
| Commit 階段是什麼? | 計算虛擬 DOM diff | 同步把計算好的 DOM 變更寫入瀏覽器 |
| 為什麼 key 很重要? | 只是為了消除主控台警告 | 提供持久身分,讓 React 能正確重用 DOM 節點 |
當你不再把 React 當成一個充滿魔法的黑盒子,而開始理解底層那套機械式流程時,一切都會改變:
useEffect 串連。console.log 印出舊值感到意外。下次你看到有人對著 setState 或 hook 錯誤抓破頭時,你可以笑著告訴他們:
「這不是魔法,只是一個快照和一條鏈結串列。」
我很想在留言區聽聽你的想法:
把你的想法留言在下面吧,我會閱讀並回覆每一則留言!