• 「宣告式」和「命令式」的差別是什麼?React 為什麼選擇宣告式?

  • Virtual DOM 存在的真正目的是什麼?(提示:不是「比較快」)

  • 為什麼直接修改(mutate)狀態,畫面不會更新?

  • 「渲染」和「更新 DOM」是同一件事嗎?

React 所有的設計——單向資料流、hooks、不可變性、—— 都是從同一個核心決定推導出來的。這一章先把這個核心講清楚, 後面每一章遇到「為什麼要這樣」的時候,答案幾乎都能追溯回這裡。

命令式的困境:手動把畫面「修」成對的樣子

先看一段沒有框架的程式碼。需求很日常:一個登入表單, email 和密碼都有值時按鈕才能按,送出時按鈕顯示「登入中…」並停用,失敗時顯示錯誤訊息。

// 命令式(vanilla JS):每個事件都要「手動修畫面」
emailInput.addEventListener('input', () => {
  submitBtn.disabled = !emailInput.value || !passwordInput.value;
  errorBox.hidden = true;            // 別忘了:使用者重新輸入時要清掉舊錯誤
});
passwordInput.addEventListener('input', () => {
  submitBtn.disabled = !emailInput.value || !passwordInput.value;
  errorBox.hidden = true;            // 同一段邏輯,第二份複本
});
submitBtn.addEventListener('click', async () => {
  submitBtn.disabled = true;
  submitBtn.textContent = '登入中…';
  try {
    await login(emailInput.value, passwordInput.value);
  } catch (e) {
    errorBox.hidden = false;
    errorBox.textContent = '帳號或密碼錯誤';
    submitBtn.textContent = '登入';
    submitBtn.disabled = false;      // 漏掉這行,按鈕就永遠鎖死
  }
});

注意每個事件處理器在做的事:它必須知道「畫面現在可能長什麼樣」, 然後下達一連串 DOM 指令,把畫面從目前的樣子修正到應該有的樣子。 這就是命令式(imperative):你描述的是「怎麼改」,而不是「該長怎樣」。

問題在於規模。畫面的樣子由多個狀態共同決定——這個例子裡至少有 「兩個欄位是否有值」「是否送出中」「是否有錯誤」。 \(k\) 個獨立的布林狀態就有 \(2^k\) 種可能的畫面; 而命令式程式要維護的不是這 \(2^k\) 種畫面本身, 是畫面之間的轉移:每個事件發生時,從任何一種畫面出發都要能改到正確的下一種。 狀態一多,轉移數量的成長遠比畫面數量快, 漏寫任何一條(例如上面「漏掉重新啟用按鈕」)就是一個 bug。 jQuery 時代大型前端難以維護,根本原因就是這個。

宣告式:只描述「每個狀態下畫面長什麼樣」

同樣的需求,React 的寫法:

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit() {
    setSubmitting(true);
    setError(null);
    try {
      await login(email, password);
    } catch {
      setError('帳號或密碼錯誤');
    } finally {
      setSubmitting(false);
    }
  }

  const canSubmit = email !== '' && password !== '' && !submitting;

  return (
    <form>
      <input value={email} onChange={(e) => setEmail(e.target.value)} />
      <input value={password} onChange={(e) => setPassword(e.target.value)} />
      {error !== null && <p className="error">{error}</p>}
      <button disabled={!canSubmit} onClick={handleSubmit}>
        {submitting ? '登入中…' : '登入'}
      </button>
    </form>
  );
}

結構上發生了一個關鍵的職責分離:

  • 事件處理器只改狀態。 裡沒有任何一行碰畫面, 它只負責描述「發生了什麼事、狀態因此變成什麼」。

  • 回傳值描述完整畫面。JSX 的部分是一個純粹的對應關係: 給定這一刻的狀態,畫面就長這樣。按鈕該不該停用、錯誤訊息在不在, 全部是從狀態算出來的,不需要「記得去改」。

「使用者重新輸入時要清掉舊錯誤」這種容易漏的轉移邏輯去哪了? 它消失了——因為畫面不再被「修改」,而是每次都從狀態重新推導, 只要狀態對,畫面就不可能停留在舊的樣子。 你維護的東西從 \(O(\text{狀態} \times \text{事件})\) 條轉移規則, 變成 \(O(\text{狀態})\) 條對應規則加上每個事件對狀態的影響。

React 的核心主張是把介面寫成狀態的純函數: 渲染就是「用目前的狀態計算畫面的描述」,事件處理就是「更新狀態」。 兩者徹底分離——事件不碰畫面,渲染不碰狀態。 你之後在公司程式碼看到的絕大多數設計慣例,都是在維護這條公式的成立。

這也直接解釋了公司程式碼的幾個「長相」:

  • 為什麼 JSX 裡到處是三元運算子、、—— 因為渲染是「計算」,條件與迴圈自然用運算式表達,而不是用指令去增刪節點。

  • 為什麼元件函式頂部常有一排 —— 那些是「每次渲染都重新計算的中間值」(例如上面的 ), 它們不是狀態,只是狀態的推導結果。這個區分在第 2 章會正式展開。

  • 為什麼事件處理器通常短小、幾乎只呼叫 —— 命令式的部分被刻意壓縮到最小。

Virtual DOM 與協調:讓「整個重畫」變得付得起

「每次狀態改變就重新計算整個畫面」聽起來很浪費。 如果每次都真的把 DOM 全部拆掉重建,效能確實不可接受—— DOM 操作牽動瀏覽器的版面計算(layout)與重繪(paint),是前端最昂貴的操作之一。

React 的解法是在你和真實 DOM 之間插入一層便宜的描述:

  1. 你的元件函式回傳的不是 DOM,而是React 元素樹—— 一種輕量的 JavaScript 物件,描述「畫面應該長什麼樣」。 JSX 只是建立這些物件的語法糖( 會被編譯成對 的呼叫)。建立幾千個 JS 物件的成本,遠低於操作幾個 DOM 節點。

  2. React 把新的元素樹跟上一次的樹做比對(這個過程叫協調,reconciliation), 找出真正有差異的地方。

  3. 只把差異的部分套用到真實 DOM 上。

不是。針對特定更新手工寫出的精準 DOM 操作,永遠可以比「產生整棵樹再 diff」更快—— diff 本身就是額外成本。Virtual DOM 真正的目的,是讓「每次都整個重算」這個 好維護的心智模型,在效能上變得可以接受。 React 用一點執行期成本,換走了你手上所有「手動同步畫面」的維護成本。 理解這一點,你才能正確看待第 3 章的效能優化:優化的對象是「計算太多次」, 而不是「DOM 更新太多次」——後者 React 已經替你最小化了。

另外一個值得記住的細節:JSX 產生的元素是「描述」而不是「實體」。 這行執行時只是建立一個 的物件, React 才是真正決定何時呼叫 函式的人。 這就是為什麼元件函式的執行時機不由你控制、為什麼它必須保持「純」—— 同樣的 props 和狀態要算出同樣的結果,不能依賴呼叫順序或次數(第 3 章的 StrictMode 會再回到這件事)。

不可變性:不是程式碼潔癖,是變更偵測的協議

三個月的 React 經驗裡,你多半已經被糾正過這種寫法:

// 錯誤:直接 mutate,畫面不會更新
function addTodo(todo: Todo) {
  todos.push(todo);     // 修改了原陣列
  setTodos(todos);      // 傳回去的還是「同一個」陣列
}

// 正確:建立新陣列
function addTodo(todo: Todo) {
  setTodos([...todos, todo]);
}

為什麼第一種不行?這不是語法規定,而是 React 變更偵測策略的直接結果。 React 需要在很多地方快速回答「這個值變了嗎」: 之後要不要重新渲染、 的依賴變了沒、 的 props 變了沒。它一律用同一個辦法: 引用比較——比「是不是同一個物件」,不比內容。

理論上 React 也可以深度比較物件內容,或像某些框架用 Proxy 攔截每一次屬性修改。 但深度比較的成本隨資料大小線性成長,大型狀態每次更新都深比會拖垮效能; 攔截式的響應系統則讓「什麼時候會觸發更新」變得隱晦。 React 選了最便宜、最可預測的協議:開發者用「新的引用」來宣告「內容變了」。 引用比較是 \(O(1)\),代價是把「建立新物件」的責任交給你。 所以不可變更新不是風格偏好,而是你和 React 之間的通訊協定—— mutate 等於對 React 說「什麼都沒變」。

  • 改物件欄位:

  • 陣列新增:

  • 陣列刪除:

  • 陣列更新單筆:

巢狀太深導致 spread 展開痛苦時,通常代表狀態結構該攤平了, 或者考慮用 Immer(公司若使用 Redux Toolkit,其內建的正是 Immer)。

把模型帶著走

這一章建立的模型可以濃縮成三句話,後面每一章都會反覆用到:

  1. 渲染是計算:元件函式用目前的狀態算出畫面描述,這個計算應該是純的。

  2. 事件改狀態,不改畫面:畫面永遠是推導結果,沒有「順手改一下 DOM」這種事。

  3. 新引用等於有變化:React 用 判斷一切「變了沒」的問題。

  • 命令式維護「狀態之間的轉移」,數量隨狀態數爆炸;宣告式只維護「狀態到畫面的對應」加上「事件對狀態的影響」。

  • UI = f(state):渲染是純計算,事件處理只更新狀態,兩者分離。

  • Virtual DOM 的目的不是絕對速度,而是讓宣告式模型的成本可接受;「渲染」(算描述)和「提交」(動 DOM)是兩個階段。

  • React 用 引用比較偵測變化,所以更新必須產生新引用;mutate 對 React 而言等於「沒變」。