大~部~分~解~ 開始!
開啟昨天的 drink-order 專案,我們已經知道 useState 怎麼運作,現在要實際觀察渲染是怎麼發生的。
在這份專案中有兩個時間點,今天主要觀察的是後續渲染:
| 時機 | 動作 |
|---|---|
| 初次顯示 | 進入首頁,框架啟動 React,產生點餐頁面 |
| 後續渲染 | 紅茶杯數從 0 更新成 1,React 再次計算對應元件的畫面 |
目前 react-router.config.ts 啟用了 ssr: true。
首次載入時,伺服器會先產生 HTML,瀏覽器再透過 hydration 接上 React 的互動邏輯。
用增加杯數當範例,useState(0) 取得杯數,按鈕則把下一個值交給 React:
// 示意片段
const [quantity, setQuantity] = useState(0);
function handleAdd() {
setQuantity(quantity + 1);
}
我們已經知道這個片段有「收到點擊」與「要求更新」。按鈕負責執行 handleAdd,函式裡的 setQuantity 才提出 state 更新,React 取值後渲染,接著往下看。
React 更新畫面的工作可以分成三個階段,最後再接到瀏覽器繪製:
| 階段 | 動作 | 過程 |
|---|---|---|
| 觸發(trigger) | 提出渲染需求 | 事件處理函式呼叫 setQuantity(1) |
| 渲染(render) | React 呼叫元件,計算這次應該顯示的內容 | DrinkCounter 取得 1,回傳使用新杯數的 JSX |
| 提交(commit) | 把計算出的必要變更套用到 DOM | 杯數文字改變,減杯按鈕從停用變成可操作 |
| 瀏覽器繪製(paint) | 瀏覽器把更新後的頁面呈現在螢幕上 | 使用者看到新的數字與按鈕外觀 |
React 的 render 指的是呼叫元件來計算畫面;commit 才把需要的變更套用到 DOM。
DrinkCounter 再次執行時,函式裡的程式會重新計算,事件處理函式與 JSX 也會重新產生。
同一個元件的 state 由 React 保存,當 React 因這次更新再次呼叫同一個元件時,useState(0) 會提供更新後的杯數,並不會直接歸零。0 只作為初始值。
上面就是我們要觀察的目標,重點是看渲染流程,以及元件 DOM 是否更新。
沿用昨天的點餐功能,加入事件與渲染 Log,把飲料名稱拆成子元件,並增加備註輸入框。
先來替換完整程式吧,下一節再拆開說明每個追蹤點的用途,最後用一次新增杯數對照 Console 與 DOM 的變化。
以下是 drink-order.tsx 的完整內容。
import { useState } from "react";
import { Link } from "react-router";
type DrinkNameProps = {
name: string;
};
function DrinkName({ name }: DrinkNameProps) {
console.log("[渲染] DrinkName", name);
return <h2>{name}</h2>;
}
type DrinkCounterProps = {
name: string;
};
function DrinkCounter({ name }: DrinkCounterProps) {
const [quantity, setQuantity] = useState(0);
console.log("[渲染] DrinkCounter", name, quantity);
function handleAdd() {
console.log("[事件] 加一杯", name);
setQuantity(quantity + 1);
}
function handleClear() {
console.log("[事件] 清空杯數", name);
setQuantity(0);
}
function handleRemove() {
console.log("[事件] 減一杯", name);
setQuantity(Math.max(quantity - 1, 0));
}
return (
<div data-drink={name}>
<DrinkName name={name} />
<p>已選 {quantity} 杯</p>
<div>
<button type="button" onClick={handleAdd}>
加一杯
</button>
<button type="button" onClick={handleClear}>
清空杯數
</button>
<button type="button" onClick={handleRemove} disabled={quantity === 0}>
減一杯
</button>
</div>
<label>
{name}備註:
<input type="text" placeholder="試著輸入晚點取" />
</label>
</div>
);
}
type DrinkDetailsProps = {
name: string;
description: string;
};
function DrinkDetails({ name, description }: DrinkDetailsProps) {
const [isExpanded, setIsExpanded] = useState(false);
console.log("[渲染] DrinkDetails", name, isExpanded);
function handleToggle() {
console.log("[事件] 切換介紹", name);
setIsExpanded(!isExpanded);
}
return (
<div>
<button type="button" aria-expanded={isExpanded} onClick={handleToggle}>
{isExpanded ? "收合" : "展開"}「{name}」介紹
</button>
{isExpanded ? <p>{description}</p> : null}
</div>
);
}
export function DrinkOrder() {
console.log("[渲染] DrinkOrder");
return (
<main>
<header>
<p>DAY 10</p>
<h1>飲料點餐練習</h1>
<p>從加一杯,追蹤渲染與 DOM 更新。</p>
<Link to="/variable">比較一般變數</Link>
</header>
<div>
<section>
<DrinkCounter name="紅茶" />
<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
</section>
<section>
<DrinkCounter name="綠茶" />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
</section>
</div>
</main>
);
}
主要是用來追蹤是哪個操作提出更新,像是增加杯數的事件處理函式:
function handleAdd() {
console.log("[事件] 加一杯", name);
setQuantity(quantity + 1);
}
[事件] 放在 handleAdd 裡,記錄這次執行的是哪款飲料的增加操作。接下來的 setQuantity 才提出 state 更新,這行 Log 用來標示操作的起點,還不能拿來判斷 DOM 是否已經改變。
主要是用來觀察元件這次讀到的杯數,將 DrinkCounter 的渲染 Log 放在取得 state 之後:
const [quantity, setQuantity] = useState(0);
console.log("[渲染] DrinkCounter", name, quantity);
這段位於元件內部,每次 React 呼叫 DrinkCounter 時,都會印出該次渲染讀到的杯數,方便我們確認 React 是否再次呼叫元件,以及計算 JSX 時使用的是什麼資料。
昨天的飲料名稱直接寫在 DrinkCounter 的 <h2> 裡。這次把它拆成 DrinkName,讓它接收相同的 name prop,並印出渲染訊息:
function DrinkName({ name }: { name: string }) {
console.log("[渲染] DrinkName", name);
return <h2>{name}</h2>;
}
DrinkCounter 的 JSX 則使用 <DrinkName name={name} />。這樣安排,是為了觀察一個沒有自己的 state、名稱也沒改變的內層元件,在外層元件更新杯數時會不會執行。
元件關係如下:
DrinkOrder
├─ DrinkCounter(紅茶,管理紅茶杯數)
│ └─ DrinkName(紅茶,接收名稱)
├─ DrinkDetails(紅茶,管理介紹開合)
├─ DrinkCounter(綠茶,管理綠茶杯數)
│ └─ DrinkName(綠茶,接收名稱)
└─ DrinkDetails(綠茶,管理介紹開合)
完整程式也在 DrinkOrder 與 DrinkDetails 加入了渲染 Log,讓我們可以比較更新範圍:除了紅茶計數器與它的內層元件,這次操作還會不會帶出外層元件、綠茶計數器或介紹元件的訊息。
Log 告訴我們函式有沒有執行,而 DOM 是否改變則需要另外觀察。因此在計數器裡放入備註輸入框:
<label>
{name}備註:
<input type="text" placeholder="試著輸入晚點取" />
</label>
這裡沒有設定 value,也沒有把備註放進 state,輸入內容由瀏覽器管理,稱為非受控輸入框。
刻意這樣安排,是為了觀察原本的輸入內容是否保留,避免由 React state 重新填入相同文字而混淆判斷。
計數器外層的 data-drink={name} 則是自訂的 HTML 資料屬性,方便在 Elements 或 Console 找到紅茶的區塊。讓我們除了查看輸入內容,也能比較更新前後的輸入框是否為同一個 DOM 節點。
這次以「紅茶從 0 杯增加到 1 杯」追蹤完整過程。因為專案有伺服器渲染(SSR),初次載入的訊息可能出現在伺服器終端機與瀏覽器 Console;以下先等頁面可以操作,再清空瀏覽器 Console,將初次顯示與這次點擊分開觀察。期間先不存檔或切換頁面,避免混入其他更新。
在紅茶備註輸入「晚點取」。這個欄位沒有呼叫 state 更新函式,所以輸入本身不會觸發本例計數器的重新渲染。
為了比較加杯前後是否沿用同一個輸入框,在瀏覽器 Console 保存它的參考:
var day10Input = document.querySelector('[data-drink="紅茶"] input');
day10Input !== null;
確認結果是 true,表示確實找到輸入框。

按紅茶的「加一杯」一次,應該會出現以下訊息。(以下省略開發模式可能重複的渲染訊息)
[事件] 加一杯 紅茶
[渲染] DrinkCounter 紅茶 1
[渲染] DrinkName 紅茶

第一行表示 handleAdd 執行了。此時事件處理函式使用原本的杯數 0,把 1 交給 setQuantity。第二行則是 React 再次呼叫紅茶的 DrinkCounter,useState 提供更新後的杯數 1,元件用它計算 JSX。
第三行是這次刻意拆出內層元件要觀察的部分:DrinkCounter 回傳了 <DrinkName name={name} />,React 接著渲染它。雖然名稱仍是「紅茶」,沒有自己的 state,內層元件仍會因這次外層元件渲染而執行。
在目前的元件結構中,單獨更新紅茶杯數不會因此重新執行 DrinkOrder、綠茶計數器或旁邊的介紹元件。雖然 DrinkDetails 與 DrinkCounter 放在同一個 <section>,但它是 DrinkCounter 的同層級元件,不是回傳的內層元件。因此這次操作只會出現紅茶計數器與名稱元件的渲染訊息。
由於目前框架預設啟用了 StrictMode,開發時可能還會看到額外的渲染訊息,但 StrictMode 不會把按鈕的事件處理函式也執行兩次。觀察圖片可以看到[渲染]有重複訊息,此時不能就認定增加杯數的事件發生兩次。
Console 說明了哪些元件執行,接下來對照畫面與 Elements 裡的紅茶區塊。從 0 杯增加到 1 杯,差異如下:
| 觀察位置 | 加杯前 | 加杯後 |
|---|---|---|
| 杯數文字 | 已選 0 杯 | 已選 1 杯 |
| 減杯按鈕 | disabled 屬性存在 |
disabled 屬性不見,可以操作 |
| 飲料名稱 | 紅茶 | 仍是紅茶 |
| 備註輸入框 | 晚點取 | 仍是晚點取 |
杯數文字與減杯按鈕都依賴 quantity,所以 JSX 的對應輸出改變了。DrinkName 雖然重新執行,但回傳的標題內容卻保持相同。表示函式被呼叫,不代表那個 DOM 節點就需要重建。
備註文字保留下來,是另一個要觀察的結果,可以把前面保存的參考與目前的節點比較:
day10Input === document.querySelector('[data-drink="紅茶"] input');

結果是 true。在這次固定結構中,輸入框的類型、位置與設定都沒改,React 沿用原本的 DOM,所以瀏覽器管理的輸入內容也就保留下來了。
這次更新是沿用同一個輸入框節點,如果重新載入整頁,或移除後重建輸入框,內容就有遺失的可能。
現在可以把程式、Log 與 DOM 的差異整理在同一條流程裡:
目前畫面:紅茶已選 0 杯,備註是「晚點取」
↓ 按「加一杯」
handleAdd 執行,印出事件 Log
↓ setQuantity(1)
觸發:React 收到狀態更新
↓
渲染:DrinkCounter 用 quantity = 1 計算 JSX
DrinkName 也執行,回傳相同的「紅茶」標題
↓
提交:更新杯數文字與按鈕狀態,沿用原本的輸入框
↓
瀏覽器繪製:看到「已選 1 杯」,減杯按鈕可以操作,備註仍在
可以觀察到元件執行的範圍,與 DOM 實際變更的範圍並不相同。 計數器與名稱元件都執行了,但需要更新的內容其實是杯數文字與按鈕狀態。
上面的流程是示範標準更新,實際上渲染 Log 與提交次數不一定是一對一的關係。一次提交可以涵蓋多個元件的計算結果,渲染也可能重試或因開發模式而額外執行,所以 Log 只能用來追蹤程式執行流程,不能當成 DOM 修改或提交次數的計數器。
有了前面的追蹤方式,遇到杯數更新異常時,就可以沿著資料往下找。假設 JSX 把數字寫成固定文字:
<p>已選 0 杯</p>

按下加杯,事件 Log 會出現,DrinkCounter 的渲染 Log 也能讀到 1,但杯數文字仍是 0。減杯按鈕卻已經可以操作,因為它使用的 disabled={quantity === 0} 仍然根據 state 計算。
這幾個結果指出:事件有執行、state 有更新、元件也重新渲染了,問題出在杯數的 JSX 沒有使用新資料。
修正方式只要把內容改回 <p>已選 {quantity} 杯</p>,就會讓渲染結果反映目前杯數。這也表示 React 會依照我們回傳的內容更新 DOM,不會自行把寫死的 0 改成訂單杯數。
今天把「按一下,畫面就變了」拆成幾個可以追蹤的步驟
明天接著看看,為什麼呼叫 setQuantity 後,同一個事件函式讀到的杯數仍然沒變。![]()