iT邦幫忙

2022 iThome 鐵人賽

DAY 14
2
Modern Web

一次打破 React 常見的學習門檻與觀念誤解系列 第 14

[Day 14] 以 functional updater 來呼叫 setState

  • 分享至 

  • xImage
  •  

在上一篇的章節中,我們詳細的解析了有關於連續呼叫 setState 時的自動 batching 機制。承著前文的脈絡,我們來探討看看一個延伸的情境:如果我們想基於 state 原有的值去計算新值並連續的 setState 的話該怎麼做?

我們先來看看以下的範例。這個範例嘗試以目前原有的 count 值去做遞增累加,並且希望每次點擊按鈕時就會連續累加 +1 三次。你可能會預期當按鈕首次被點擊時,counter 會從預設值 0 被 +1 三次,總共 +3 所以更新結果是 3

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
	const handleButtonClick = () => {
	  setCount(count + 1);
      setCount(count + 1);
      setCount(count + 1);
	};

  return (
    <>
      <h1>{count}</h1>
      <button onClick={handleButtonClick}>+3</button>
    </>
  )
}

但實際執行起來並非如此,而是只會 +1,更新後的結果是 1

Apr-18-2022 16-01-38.gif

這是因為每一次的 render 都有它自己版本的 state 值,同一次 render 中的 state 值是固定且永遠不變的。因此你可以想像在 Counter component 在首次 render 時, count 變數其實可以視為一個值為 0 的常數:

function Counter() {
  // 以下這段程式是在示意 count state 的值是 0 的時候的 render 過程
  const count = 0; // 從 useState 取出的 state 值

	const handleButtonClick = () => {
      // 由於 closure 的特性,這個函式作用域中的 count 變數的值永遠都會是 0 
  
	  setCount(count + 1);
      setCount(count + 1);
      setCount(count + 1);
	};

// ...

如上所示,當我們第一次執行 handleButtonClick 時, count 的值是 0 ,所以由於 closure 的特性,這個函式作用域中的 count 變數的值永遠都會是 0

當事件中第一次呼叫 setCount(count + 1) 後,這個 handleButtonClick 仍然會繼續執行,它仍是由原本的 render 中所建立的 closure 函式,所以此時在這個事件的作用域中的 count 變數的值當然也仍是原本的值 0。因此,這三次 setCount 所做的事情其實就等同於連續呼叫了三次 setCount(0 + 1)

function Counter() {
  // 以下這段程式是在示意 count state 的值是 0 的時候的 render 過程
  const count = 0; // 從 useState 取出的 state 值

	const handleButtonClick = () => {
      // 由於 closure 的特性,這個函式作用域中的 count 變數的值永遠都會是 0

      // 第一次呼叫 setCount 時,將「取代為 1」這個動作加到待執行佇列中
	  setCount(0 + 1); 
      
      // 第二次呼叫 setCount 時,將「取代為 1」這個動作加到待執行佇列中
      setCount(0 + 1);
      
      // 第三次呼叫 setCount 時,將「取代為 1」這個動作加到待執行佇列中
      setCount(0 + 1);
    
      // 以 1 作為 count state 的更新結果來 re-render
	};

// ...

所以這三次 setState 其實都有正常的執行到,只是實際上它們執行時傳入的資料都是 0 + 1 ,所以最後 setCount 的待執行佇列的試算結果當然也就會是 1

而解決這種需求的方法很簡單,就是改以 updater function 的形式來進行 setState 的呼叫

提示:
如果你對於上面所描述的 由於 closure 的特性,這個函式作用域中的 count 變數的值永遠都會是 0 這種行為一頭霧水的話,有可能是因為對於 JavaScript 的核心特性「Closure 閉包函式」還沒有很熟悉。非常建議先將其徹底搞懂再繼續進行 React 的學習,因為 React 的本身的核心概念與設計當中就大量的應用了這個 JavaScript 的特性。建議可以參考一下前面的章節「[Day 02] 學好 React 需要的前置基本功」中的推薦學習資源。


以 updater function 的形式來進行 setState 的呼叫

useState 所提供的 setState 方法,在呼叫時除了可以直接傳入目標的新值作為參數以外,其實也可以傳入一個 updater function 做為參數:

setCount(prevValue => prevValue + 1);

上面 prevValue => prevValue + 1 這個傳入的自定義函式就被叫作 updater function。這個 updater function 會在到時候實際執行時被注入一個舊的值做為參數,然後必須返回一個新的值。一個 updater function 意味著你告訴 React 這次 setState 的資料動作是是想要「以目前為止的原資料經過某些計算後產生新的資料」,而非「直接取代為某個值」。

當然,當我們以 updater function 來呼叫 setState 方法時,這個 updater function 並不會立即的被執行,而是會像之前直接傳值時一樣,被記錄到一個 state 更新動作的待執行佇列中。不同的是,此時紀錄的內容會是這個 updater function 本身。為了方便表示,以下我們會用 n 這個變數名來代稱「 updater function 注入的參數」:

function Counter() {
  const [count, setCount] = useState(0);
	const handleButtonClick = () => {
      // 第一次呼叫時,將「n => n + 1」這個動作加到待執行佇列中
	  setCount(n => n + 1);
    
      // 第二次呼叫時,將「n => n + 1」這個動作加到待執行佇列中
      setCount(n => n + 1);

      // 第三次呼叫時,將「n => n + 1」這個動作加到待執行佇列中
      setCount(n => n + 1);

      // 執行到這裡時,這個事件 callback 已經沒有後續的事情需要處理了,
      // 此時就會開始統一進行一次 re-render,
      // 並且依序試算 count state 的待執行佇列的結果:
      // 原值 => 「n => n + 1」 => 「n => n + 1」 => 「n => n + 1」
	};

  // ...
}

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

當第一個 updater 計算時,會將目前 render 的 state 值傳作參數 n 來傳入,而這個 updater 產出的計算結果又會成為佇列中下一個 updater 的參數 n

舉例來說,當 count state 的值是 0 的該事件執行時, 0 就會被當作第一個 updater n => n + 1 的參數注入,所以運算結果是 0 + 1 = 1,接著這個結果 1 又會被當作下一個 updater 的參數 n,所以第二個 updater 的計算結果是 1 + 1 = 2 …以此類推:

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

如此一來,我們就能透過 updater function 的方式來進行 setState 的連續呼叫計算。

需要特別注意的是,為了保證 updater function 每次執行的效果一致,因此它必須是一個 pure function,其中不應該包含任何副作用。在 Strict mode 底下,React 會在每次執行 updater function 時都自動重複跑兩次(但是會無視第二次的執行結果),以協助你檢查到其可能包含的副作用。


以取代值與 updater function 的動作交叉呼叫 setState

然而如果我們交叉以普通的取代值以及 updater function 來呼叫 setState 的話,會發生什麼事情呢?觀察以下的範例:

function Counter() {
  const [count, setCount] = useState(0);
	const handleButtonClick = () => {
      // 第一次呼叫時,將「取代為 count + 3」這個動作加到待執行佇列中
	  setCount(count + 3);
    
      // 第二次呼叫時,將「n => n + 5」這個動作加到待執行佇列中
      setCount(n => n + 5);

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

  // ...
}

「取代為某個值」的計算結果也會作為下一個 updater function 的參數 n 來傳入。我們一樣以 count state 的值是 0 的時候為例:

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

當然,如果你在 updater function 之後有取代的動作,就會直接覆蓋過去:

function Counter() {
  const [count, setCount] = useState(0);
	const handleButtonClick = () => {
      // 第一次呼叫時,將「取代為 count + 3」這個動作加到待執行佇列中
	  setCount(count + 3);
    
      // 第二次呼叫時,將「n => n + 5」這個動作加到待執行佇列中
      setCount(n => n + 5);

      // 第三次呼叫時,將「取代為 100」這個動作加到待執行佇列中
      setCount(100);
      
      // 執行到這裡時,這個事件 callback 已經沒有後續的事情需要處理了,
      // 此時就會開始統一進行一次 re-render,
	  // 並且依序試算 count state 的待執行佇列的結果:
      // 原值 => 「取代為 count + 3」 => 「n => n + 5」=> 「取代為 100」
	};

  // ...
}

一樣以 count state 的值是 0 的時候為例:

https://i.imgur.com/7KOC2Xx.png

可以從上面的流程示意圖看到,雖然前面兩個動作之後的計算結果是 8 ,但是由於第三個動作是「取代為 100」,因此在執行之後結果就會直接覆蓋為 100


參考資料


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 13] 深入理解 batch update
下一篇
[Day 15] 維持 React 資料流可靠性的核心關鍵:Immutable state
系列文
一次打破 React 常見的學習門檻與觀念誤解30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
112182ssss
iT邦新手 4 級 ‧ 2023-09-15 19:30:25

好喜歡這篇,看完整個思路很清晰,好愛搭配說明的圖片,感謝!

我要留言

立即登入留言