iT邦幫忙

2022 iThome 鐵人賽

DAY 13
5

從前面的章節中我們已經充分地了解到,當呼叫 setState 方法時就會觸發對應 state 定義的 component 的 re-render。然而當我們呼叫 setState 之後如果再次呼叫 setState 呢?這個章節當中我們將會進一步解析關於 setState 方法與 re-render 機制的一些特性。


什麼是 batch update

當我們呼叫 setState 方法時,會觸發 component 的 re-render。但是 re-render 的觸發其實並不是立即的,意思也就是說當呼叫 setState(newValue) 的這行執行完畢並開始進行下一行時,其實 re-render 的動作還沒有真正開始。以下的範例我們嘗試在同一個事件處理中連續多次呼叫 setState,但你會發現最後實際上只會 re-render 一次

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
	const handleButtonClick = () => {
	  setCount(1);
	  // 執行到這裡時,其實 re-render 的動作還不會開始

	  setCount(2);
	  // 執行到這裡時,其實 re-render 的動作還不會開始

	  setCount(2);

	  // 執行到這裡時,這個事件已經沒有其他程式需要執行了,開始進行一次 re-render
	};

  // ...
}

React 其實會在你正在執行的事件內的所有程式都結束後,才會開始進行 re-render 的動作。這就是為什麼在上面的範例中,當 handleButtonClick 裡的所有 setState 都呼叫完之後,component 才真正開始 re-render 的動作。當我們每次呼叫 setState 方法時,React 會將呼叫的動作依序紀錄到一個待執行計算的佇列(queue)中,然後合併試算並只進行一次 re-render 來完成畫面更新

這有點像是當我們想更新一篇部落格文章,在編輯器中可以先多次的任意刪改當下的草稿內容,但並不會立即的生效。直到你最後一次性的正式按送出時,才會以最後版本的內容來真正執行保存。

例如在以下的範例中,當我們連續呼叫 setState 但每次都傳入不同的值的時候,你可能會更容易理這個概念:

import { useState } from 'react';

export default function Counter() {
  const [count, setCount] = useState(0);
	const handleButtonClick = () => {
        // 第一次呼叫時,將「取代為 1」這個動作加到待執行佇列中,
        // 但還沒開始 re-render 也還沒真正更新 state 的值
		setCount(1); 

        // 第二次呼叫時,將「取代為 2」這個動作加到待執行佇列中,
        // 但還沒開始 re-render 也還沒真正更新 state 的值
        setCount(2); 

        // 第三次呼叫時,將「取代為 3」這個動作加到待執行佇列中,
        // 但還沒開始 re-render 也還沒真正更新 state 的值
        setCount(3); 

		// 執行到這裡時,這個事件 callback 已經沒有後續的事情需要處理了,
        // 此時就會開始統一進行一次 re-render,
		// 並且依序試算 count state 的待執行佇列的結果:原值 => 取代為 1 => 取代為 2 => 取代為 3
		// 因此最後會將 count state 的值直接更新成 3
	};

  // ...
}

以上這個範例中,當你第一次點擊按鈕時,count state 的值並不會分別以 123 作為新值依序進行三次 re-render,而是會從 0 在只經過一次 re-render 後直接被更新成 3

https://i.imgur.com/U2Jp5VG.png

這種「一個事件中多次呼叫 setState 時,會自動依序合併試算 state 的目標更新結果,並只統一呼叫一次 re-render 來完成畫面更新」,藉由減少不必要的 re-render 來提升效能的機制,就被稱作「batch update」或是「automatic batching」。

補充說明:即使不同 state 的 setState 交叉連續呼叫也會支援自動 batching

這裡要注意的是,自動 batching 並不是只有在連續呼叫同一種 state 的 setState 方法時才會生效,而是任何 state 所對應的 setState 方法混著互叫時都可以支援。

例如以下範例中,component 同時有 countname 兩種 state,我們嘗試在同一個事件中連續且混著呼叫它們的 setState 方法:

function App() {
  const [count, setCount] = useState(0);
	const [name, setName] = useState('Zet');

  const handleClick = () => {
	setCount(1);
    setName('Foo');
	setName('Bar');
    setCount(2);
    setCount(3);

    // 以上的多次且混用的 setState 呼叫 總共只會導致觸發一次 re-render:
    // 以 3 作為 count 的最後更新結果,且同時以 'Bar' 作為 name 的最後更新結果
  };

  // ...
}


React 18 對於 automatic batching 的全面支援

然而在 React 18 之前的版本中 (≤ 17),batch update 其實只會在同步的 React 事件中才會自動支援。如果在一些非同步事件中去多次呼叫 setState 的話,仍會多次觸發 re-render:

// React 版本 <= 17 時

setTimeout(
  () => {
    setCount(1);
    setCount(2);
    setCount(3);

    // 此時會為了上面的三次 setState 分別依序進行 re-render,共三次 re-render,
	// 無法自動支援 batch update
  },
  1000
);

其它還有像是在 promise.then() callback 中、原生的 DOM event callback…等等地方連續呼叫 setState 的話,React ≤ 17 的舊版本都無法在這些情境支援自動的 batching 機制。

不過好消息是從 React 18 開始,React 將會對於所有情境下的 setState 都完整支援自動 batching:

// React 版本 >= 18 時

setTimeout(
  () => {
    setCount(1);
    setCount(2);
    setCount(3);
    // 此時 React 會以 3 作為 setCount 的更新結果,只進行一次 re-render
  },
  1000
);

你可以放心的在任何地方連續呼叫 setState,React 將會全面性的自動支援 batching。

但是如果我不想要 batch update 時怎麼辦: flushSync()

在絕大多數情況下,自動 batching 的機制對於你的 React app 都是安全且符合預期行為的。因為如果你因為一些商業邏輯而在一次事件 callback 事件中多次呼叫了 setState通常我們其實不在乎過程中間那幾次 setState 呼叫有沒有立即的更新到瀏覽器畫面上,而是只在乎所有 setState 都執行完成後的最後結果畫面是否正確與資料對應就好。因此 React 在預設情況下會將 setState 做合併計算處理,並以結果來只執行一次 re-render 完成畫面更新。此時自動 batching 機制就是符合預期的行為,你不需要有任何額外的處理 React 就會幫你搞定。

不過在某些特殊的需求下,你有可能會有想要在一次 setState 立即觸發 re-render,並立刻觀察這次 re-render 所造成的真實 DOM 畫面結果。而 React 18 也提供了一個叫 flushSync() 的新 API 來支援這種情境需求:

// React 版本 >= 18
import { flushSync } from 'react-dom'; // 注意:是從 react-dom 裡 import,而不是 react

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Zet');

  const handleClick = () => {
    flushSync(() => {
      setCount(1);

      // 此時會先為了這個 flushSync 裡面所呼叫的 setState 進行一次 re-render
    });

	// 執行到這裡時 React 已經執行完畢上面那次 setCount(1) 所觸發 state 正式更新以及 re-render,
    // 且真實的 DOM 已經被操作更新完畢了

    flushSync(() => {
      setName('Foo');

      // 此時會為了這個 flushSync 裡面所呼叫的 setState 再進行一次 re-render
    });
  
    // 執行到這裡時 React 已經執行完畢上面那次 setName('Foo') 所觸發的 re-render,
    // 且真實的 DOM 已經被操作更新完畢了
  };

  // ...
}

需要特別注意的是,雖然當 flushSync 執行完畢時也代表了其中的 setState 所對應的 re-render 也已經執行完畢,但是此時當你接著去讀取 useState 返回的 state 值時,你會發現它的值仍然會是更新前的原值。這是因為此時正在執行中的 handleClick 事件是基於 state 尚未被更新的 render 所建立的,而在同一次 render 中,state 的值是永遠不變的:

import { flushSync } from 'react-dom';

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState('Zet');

  const handleClick = () => {
    flushSync(() => {
      setCount(1);

      // 此時會先為了這個 flushSync 裡面所呼叫的 setState 進行一次 re-render
    });

	// 執行到這裡時 React 已經執行完畢上面那次 setCount(1) 所觸發 state 正式更新以及 re-render,
    // 且真實的 DOM 已經被操作更新完畢了

    // 首次點擊 handleClick 的話,
    // 此時讀取 count 變數的值仍然會是 0,
    // 這是因為此時正在執行中的 handleClick 事件是基於 count 仍是 0 的那次 render 所建立的 closure
    console.log(count); // 0
  };

  // ...
}

這代表著你只有在新一次執行的 render 當中才會從 useState 中取得對應的新版本的值。我們在後續的章節中也會進一步深入探討這個關於 render 的資料流概念。


參考資料


2024/2 更新 - 實體書平裝版本預購

在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~

《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》

目前首刷的軟精裝版本各大通路已經幾乎都銷售一空,接下來會再刷推出新的平裝版本:

天瓏(平裝版預購):
https://www.tenlong.com.tw/products/9786263337695

博客來(平裝版):
https://www.books.com.tw/products/0010982322

momo(平裝版):
https://www.momoshop.com.tw/goods/GoodsDetail.jsp?i_code=12528845


上一篇
[Day 12] 如何在子 component 裡觸發更新父 component 的資料
下一篇
[Day 14] 以 functional updater 來呼叫 setState
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
Vader
iT邦研究生 4 級 ‧ 2023-02-15 10:39:34

哪如果我要在這次onclick 就點擊到顯示正確的值 這樣要怎麼改寫下面這段

const handleClick = () => {
    flushSync(() => {
      setCount(1);
    });
    console.log(count); //期望值拿到1的話???
  };
Zet iT邦新手 2 級 ‧ 2023-02-20 09:53:44 檢舉

無法做到,在該次 render 中 count 永遠都會是 setState 前固定值,你只能在下一次的 render 當中才能看到新版的 count 值

我要留言

立即登入留言