在上一篇的章節中,我們詳細的解析了有關於連續呼叫 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
:
這是因為每一次的 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 需要的前置基本功」中的推薦學習資源。
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」
};
// ...
}
當第一個 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
…以此類推:
如此一來,我們就能透過 updater function 的方式來進行 setState
的連續呼叫計算。
需要特別注意的是,為了保證 updater function 每次執行的效果一致,因此它必須是一個 pure function,其中不應該包含任何副作用。在 Strict mode 底下,React 會在每次執行 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
的時候為例:
當然,如果你在 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
的時候為例:
可以從上面的流程示意圖看到,雖然前面兩個動作之後的計算結果是 8
,但是由於第三個動作是「取代為 100
」,因此在執行之後結果就會直接覆蓋為 100
。
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《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