你是否曾經寫過一行 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 變成螢幕上的像素。

拿起你最喜歡的咖啡,我們開始吧。


心智模型陷阱:初學者以為的樣子 vs 真實情況

當大多數人學 React 時,大腦會建立一個非常簡單的運作模型:

  1. 你呼叫 setCount(1)
  2. React 進到你的 HTML 裡
  3. 它把 <button>Count: 0</button> 改成 <button>Count: 1</button>
  4. 完成

初學者以為 React 的運作方式 vs 實際運作方式

如果 React 真的是這樣運作,那它就太糟了。每次 state 更新都直接搜尋並修改瀏覽器 DOM,速度慢又昂貴。瀏覽器必須重新計算樣式、重新計算版面幾何(reflow),並重繪像素。

實際上,React 做的是完全不同的事情:

你呼叫 setState 時,React 並不會立刻更新 DOM,而是排程一個未來要執行的計算。

讓我們拆解每次元件更新時都會發生的三個不同階段。


三個階段:觸發、渲染、提交

把 React 想像成一家高級餐廳的廚房。

  • 觸發階段(Trigger Phase):客人下單。
  • 渲染階段(Render Phase):廚師在廚房裡私下做菜並擺盤。
  • 提交階段(Commit Phase):服務生把餐點端到客人的桌上。

如果客人在料理到一半時改變主意,廚師可以把那盤半成品丟掉,客人完全不會看到。但一旦盤子上桌,那就是提交。

對應到 React,大概是這樣:

[使用者互動]
       |
       v
1. 觸發階段(dispatchSetState 佇列化一個更新請求)
       |
       v
2. 渲染階段(純 JavaScript 計算:呼叫你的元件、比對 Fiber 樹)
       |
       v
3. 提交階段(同步 DOM 變更:document.createElement、appendChild)
       |
       v
4. 瀏覽器繪製(瀏覽器將更新後的像素畫到你的螢幕上)

我們逐一來看。

1. 觸發階段

當你呼叫 setCount(count + 1) 時,你的元件函式不會立刻再次執行。

相反地,React 會建立一個 Update Object,並把它放進附加在該元件內部表示上的更新佇列。React 會把這個元件標記為「dirty」,並透過 React Scheduler 排程工作。

如果你快速點三下按鈕,或是在同一個事件處理器中更新三個不同的 state 變數,React 會把它們批次處理。它不會為了三份麵包棒就把廚房跑三次,而是先把整張訂單收齊。

2. 渲染階段(純 JavaScript)

這是最容易讓人困惑的階段,因為名稱會誤導人。

開發者聽到「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。

3. 提交階段

現在 React 已經知道所需變更的最小集合,於是進入提交階段。

這個階段是同步的,而且不能被中斷。React 會透過原生瀏覽器 API 直接操作真實 DOM:

domNode.textContent = '1';

只有那個變更的文字節點會被碰到,其他 DOM 樹完全不受影響。

一旦提交階段結束並且 layout effects 執行完,瀏覽器引擎就接手,將更新後的像素繪製到你的螢幕上。


為什麼 Hook 的順序很重要:Fiber 鏈結串列

你是否曾經好奇,為什麼 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 節點上。

為什麼 Hook 順序規則存在: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:

  1. 你呼叫 useState():React 讀取 Hook 0('Dan')。指標前進到 Hook 1。
  2. 你呼叫 useEffect():React 讀取 Hook 1。指標前進到 Hook 2。
  3. 你呼叫 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。

  1. 第 2 行呼叫 useState():React 讀取 Hook 0(name)。指標前進到 Hook 1。
  2. 第 4 行條件式為 false!useEffect 被跳過。
  3. 第 10 行呼叫 useState():React 讀取 Hook 1!但 Hook 1 原本應該是 effect 物件,不是你的 age state!

資料完全亂掉了。React 偵測到呼叫的 hook 數量與鏈結串列不一致,拋出錯誤,並在損壞資料之前停止執行。

這就是為什麼 hook 順序在每一次渲染中都必須 100% 穩定。


快照心智模型:為什麼 state 是常數

回到我們一開始的謎題:

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 都是常數。

State Closure Snapshot Diagram

當 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>
  );
}

試試看:

  1. 在輸入框中輸入 "Hello"。
  2. 點擊「3 秒後送出」。
  3. 立刻把輸入文字改成 "Goodbye"。
  4. 等待 alert。

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 渲染的真正規則是:

真正的觸發規則

一個元件會重新渲染,當且僅當:

  1. 它自己的 state 改變了(透過 useState 或 useReducer)
  2. 它訂閱的 Context 改變了(透過 useContext)
  3. 它的父元件重新渲染了

預設情況下,只要父元件重新渲染,React 就會遞迴地重新渲染它所有的子元件、孫元件與後代元件,不管它們的 props 有沒有變。

為什麼 React 要這樣做?因為在 95% 的 Web 應用中,呼叫一次 JavaScript 函式、回傳幾個虛擬 DOM 物件,只需要 0.01 毫秒。React 會假設渲染是便宜且安全的。

如何避免連鎖效應,而不過度使用 useMemo

初學者看到子元件重新渲染時,常常會驚慌,立刻把每個函式都包上 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。只有乾淨的元件架構。


為什麼 key 很重要:列表 diff 的謎團

你是否曾經在 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」,反而第三個輸入框消失了!

為什麼 key 很重要:列表 diff 時實際發生的事

為什麼會這樣?

React 的 diff 演算法會根據兩件事比較子元素:元素類型與Key。

當你把陣列索引當作 key 時:

  • 第 1 次渲染:

    • Key 0 -> Buy milk
    • Key 1 -> Walk dog
    • Key 2 -> Read book
  • 第 2 次渲染(刪掉 Buy milk 後):

    • Key 0 -> Walk dog
    • Key 1 -> Read book

React 會把舊的 key 0 和新的 key 0 做比較。兩者都是 key 0。兩者都是 <li><input /></li>。

React 會說:「太好了!Key 0 是同一個元素。我會保留現有的 DOM 節點及其內部狀態。」

因為 <input /> 有瀏覽器 DOM 的本地狀態(你輸入的文字),React 會保留舊的輸入框,不去更新它。接著 React 會看 Key 2。Key 2 在新列表中不見了,所以 React 會刪掉第三個 DOM 節點!

key 的黃金法則

Key 不只是為了消除瀏覽器主控台裡那個黃色 React 警告而存在。

Key 是元件在不同渲染之間的持久身分辨識。

當你給元素一個穩定且唯一的 key(例如 key={item.id}):

  1. 即使專案的位置從 index 0 移到 index 10,React 仍能匹配到同一筆資料。
  2. 重排列表時,只會移動 DOM 節點,而不是銷毀再重建。
  3. 元件 state 會正確附著在對應的資料專案上。

用 25 行程式碼做出你自己的迷你 React

要真正鞏固對 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");

注意這段小小程式碼中發生了什麼:

  1. hooks 是一個簡單的陣列,用來儲存元件外部的值。
  2. currentHook 是一個索引,每次渲染前會重設為 0。
  3. 當你呼叫 useState 時,它會讀取 hooks[currentHook] 並將索引加一。
  4. 呼叫 setState 會更新陣列中的專案,並觸發 render()。

真正的 React 會在 Fiber 節點上使用鏈結串列、優先序佇列和並行排程,但其基本架構和這 25 行程式碼是相同的。

沒有魔法。只有陣列、指標,以及函式呼叫函式。


React 心智模型完整速查表

以下是可以收藏的快速參考表:

問題 常見迷思 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 當成一個充滿魔法的黑盒子,而開始理解底層那套機械式流程時,一切都會改變:

  • 你不再為了「同步」state 而寫一堆防禦性的 useEffect 串連。
  • 你不會再對 console.log 印出舊值感到意外。
  • 你會精準知道元件何時以及為什麼會重新渲染。
  • 你會寫出更快、bug 更少的程式碼。

下次你看到有人對著 setState 或 hook 錯誤抓破頭時,你可以笑著告訴他們:

「這不是魔法,只是一個快照和一條鏈結串列。」


一起討論!

我很想在留言區聽聽你的想法:

  1. 你剛開始學 React 時,第一個把你腦袋徹底搞亂的行為是什麼?
  2. 你有沒有在 production 裡被陣列 index 當 key 的 bug 傷過?
  3. 你在專案中通常怎麼處理不必要的重新渲染:組合還是 memo 化?

把你的想法留言在下面吧,我會閱讀並回覆每一則留言!


原文出處:https://dev.to/smtahosin/how-react-actually-works-under-the-hood-and-why-your-mental-model-might-be-wrong-12b8


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝49   💬2  
698
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登