iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 10 篇

Day 10 : 按部就班,拆解 React 更新畫面的過程

  • 分享至 

  • xImage
  •  

大~部~分~解~ 開始!

今天目標

  1. 辨認渲染的觸發時機
  2. 理解渲染與提交
  3. 建立 DOM 更新監看與追蹤元件的 Log
  4. 執行觀察
  5. 錯誤修正練習

辨認渲染的觸發時機

開啟昨天的 drink-order 專案,我們已經知道 useState 怎麼運作,現在要實際觀察渲染是怎麼發生的。

在這份專案中有兩個時間點,今天主要觀察的是後續渲染:

時機 動作
初次顯示 進入首頁,框架啟動 React,產生點餐頁面
後續渲染 紅茶杯數從 0 更新成 1,React 再次計算對應元件的畫面

初次顯示

目前 react-router.config.ts 啟用了 ssr: true。

首次載入時,伺服器會先產生 HTML,瀏覽器再透過 hydration 接上 React 的互動邏輯。

React:hydrateRoot

後續渲染

用增加杯數當範例,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。

重新執行元件,會把 state 歸零嗎?

DrinkCounter 再次執行時,函式裡的程式會重新計算,事件處理函式與 JSX 也會重新產生。

同一個元件的 state 由 React 保存,當 React 因這次更新再次呼叫同一個元件時,useState(0) 會提供更新後的杯數,並不會直接歸零。0 只作為初始值。

React:useState

上面就是我們要觀察的目標,重點是看渲染流程,以及元件 DOM 是否更新。

建立 DOM 更新監看與追蹤元件的 Log

沿用昨天的點餐功能,加入事件與渲染 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>
  );
}

功能拆解說明

事件 Log

主要是用來追蹤是哪個操作提出更新,像是增加杯數的事件處理函式:

function handleAdd() {
  console.log("[事件] 加一杯", name);
  setQuantity(quantity + 1);
}

[事件] 放在 handleAdd 裡,記錄這次執行的是哪款飲料的增加操作。接下來的 setQuantity 才提出 state 更新,這行 Log 用來標示操作的起點,還不能拿來判斷 DOM 是否已經改變。

計數器 Log

主要是用來觀察元件這次讀到的杯數,將 DrinkCounter 的渲染 Log 放在取得 state 之後:

const [quantity, setQuantity] = useState(0);

console.log("[渲染] DrinkCounter", name, quantity);

這段位於元件內部,每次 React 呼叫 DrinkCounter 時,都會印出該次渲染讀到的杯數,方便我們確認 React 是否再次呼叫元件,以及計算 JSX 時使用的是什麼資料。

名稱內層元件 Log

昨天的飲料名稱直接寫在 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,讓我們可以比較更新範圍:除了紅茶計數器與它的內層元件,這次操作還會不會帶出外層元件、綠茶計數器或介紹元件的訊息。

DOM 觀察:重新渲染後,輸入框還是原來的嗎?

Log 告訴我們函式有沒有執行,而 DOM 是否改變則需要另外觀察。因此在計數器裡放入備註輸入框:

<label>
  {name}備註:
  <input type="text" placeholder="試著輸入晚點取" />
</label>

這裡沒有設定 value,也沒有把備註放進 state,輸入內容由瀏覽器管理,稱為非受控輸入框。

刻意這樣安排,是為了觀察原本的輸入內容是否保留,避免由 React state 重新填入相同文字而混淆判斷。

React:Controlling an input with a state variable

計數器外層的 data-drink={name} 則是自訂的 HTML 資料屬性,方便在 Elements 或 Console 找到紅茶的區塊。讓我們除了查看輸入內容,也能比較更新前後的輸入框是否為同一個 DOM 節點。

執行觀察

這次以「紅茶從 0 杯增加到 1 杯」追蹤完整過程。因為專案有伺服器渲染(SSR),初次載入的訊息可能出現在伺服器終端機與瀏覽器 Console;以下先等頁面可以操作,再清空瀏覽器 Console,將初次顯示與這次點擊分開觀察。期間先不存檔或切換頁面,避免混入其他更新。

先留下備註與 DOM 節點

在紅茶備註輸入「晚點取」。這個欄位沒有呼叫 state 更新函式,所以輸入本身不會觸發本例計數器的重新渲染。

為了比較加杯前後是否沿用同一個輸入框,在瀏覽器 Console 保存它的參考:

var day10Input = document.querySelector('[data-drink="紅茶"] input');
day10Input !== null;

確認結果是 true,表示確實找到輸入框。

https://ithelp.ithome.com.tw/upload/images/20260924/20184332zS91IiWf81.png

按下加一杯,比對事件與渲染 Log

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

[事件] 加一杯 紅茶
[渲染] DrinkCounter 紅茶 1
[渲染] DrinkName 紅茶

https://ithelp.ithome.com.tw/upload/images/20260924/20184332HlvelfKwyC.png

第一行表示 handleAdd 執行了。此時事件處理函式使用原本的杯數 0,把 1 交給 setQuantity。第二行則是 React 再次呼叫紅茶的 DrinkCounter,useState 提供更新後的杯數 1,元件用它計算 JSX。

第三行是這次刻意拆出內層元件要觀察的部分:DrinkCounter 回傳了 <DrinkName name={name} />,React 接著渲染它。雖然名稱仍是「紅茶」,沒有自己的 state,內層元件仍會因這次外層元件渲染而執行。

在目前的元件結構中,單獨更新紅茶杯數不會因此重新執行 DrinkOrder、綠茶計數器或旁邊的介紹元件。雖然 DrinkDetails 與 DrinkCounter 放在同一個 <section>,但它是 DrinkCounter 的同層級元件,不是回傳的內層元件。因此這次操作只會出現紅茶計數器與名稱元件的渲染訊息。

React:React renders your components

由於目前框架預設啟用了 StrictMode,開發時可能還會看到額外的渲染訊息,但 StrictMode 不會把按鈕的事件處理函式也執行兩次。觀察圖片可以看到[渲染]有重複訊息,此時不能就認定增加杯數的事件發生兩次。

React:Fixing bugs found by double rendering in development

再看 DOM 實際改了什麼

Console 說明了哪些元件執行,接下來對照畫面與 Elements 裡的紅茶區塊。從 0 杯增加到 1 杯,差異如下:

觀察位置 加杯前 加杯後
杯數文字 已選 0 杯 已選 1 杯
減杯按鈕 disabled 屬性存在 disabled 屬性不見,可以操作
飲料名稱 紅茶 仍是紅茶
備註輸入框 晚點取 仍是晚點取

杯數文字與減杯按鈕都依賴 quantity,所以 JSX 的對應輸出改變了。DrinkName 雖然重新執行,但回傳的標題內容卻保持相同。表示函式被呼叫,不代表那個 DOM 節點就需要重建。

備註文字保留下來,是另一個要觀察的結果,可以把前面保存的參考與目前的節點比較:

day10Input === document.querySelector('[data-drink="紅茶"] input');

https://ithelp.ithome.com.tw/upload/images/20260924/20184332W55SVuxlyr.png

結果是 true。在這次固定結構中,輸入框的類型、位置與設定都沒改,React 沿用原本的 DOM,所以瀏覽器管理的輸入內容也就保留下來了。

React:React commits changes to the DOM

這次更新是沿用同一個輸入框節點,如果重新載入整頁,或移除後重建輸入框,內容就有遺失的可能。

把觀察結果接回更新流程

現在可以把程式、Log 與 DOM 的差異整理在同一條流程裡:

目前畫面:紅茶已選 0 杯,備註是「晚點取」
  ↓ 按「加一杯」
handleAdd 執行,印出事件 Log
  ↓ setQuantity(1)
觸發:React 收到狀態更新
  ↓
渲染:DrinkCounter 用 quantity = 1 計算 JSX
      DrinkName 也執行,回傳相同的「紅茶」標題
  ↓
提交:更新杯數文字與按鈕狀態,沿用原本的輸入框
  ↓
瀏覽器繪製:看到「已選 1 杯」,減杯按鈕可以操作,備註仍在

可以觀察到元件執行的範圍,與 DOM 實際變更的範圍並不相同。 計數器與名稱元件都執行了,但需要更新的內容其實是杯數文字與按鈕狀態。

上面的流程是示範標準更新,實際上渲染 Log 與提交次數不一定是一對一的關係。一次提交可以涵蓋多個元件的計算結果,渲染也可能重試或因開發模式而額外執行,所以 Log 只能用來追蹤程式執行流程,不能當成 DOM 修改或提交次數的計數器。

錯誤修正練習

Log 裡的杯數變了,畫面卻停在 0

有了前面的追蹤方式,遇到杯數更新異常時,就可以沿著資料往下找。假設 JSX 把數字寫成固定文字:

<p>已選 0 杯</p>

https://ithelp.ithome.com.tw/upload/images/20260924/20184332KJsKzMVnwk.png

按下加杯,事件 Log 會出現,DrinkCounter 的渲染 Log 也能讀到 1,但杯數文字仍是 0。減杯按鈕卻已經可以操作,因為它使用的 disabled={quantity === 0} 仍然根據 state 計算。

這幾個結果指出:事件有執行、state 有更新、元件也重新渲染了,問題出在杯數的 JSX 沒有使用新資料。

修正方式只要把內容改回 <p>已選 {quantity} 杯</p>,就會讓渲染結果反映目前杯數。這也表示 React 會依照我們回傳的內容更新 DOM,不會自行把寫死的 0 改成訂單杯數。


今天把「按一下,畫面就變了」拆成幾個可以追蹤的步驟

  • 事件提出更新
  • React 呼叫元件計算結果
  • 差異提交到 DOM,由瀏覽器呈現

明天接著看看,為什麼呼叫 setQuantity 後,同一個事件函式讀到的杯數仍然沒變。/images/emoticon/emoticon13.gif

參考資料


上一篇
Day 9 : 念念不忘,用 state 讓元件記住資料
下一篇
Day 11 : 此時此刻,理解 state 的渲染快照
系列文
React 入門到實作與除錯|30 天哩ㄟ刻 共 18 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言