從前面的章節中我們已經充分地了解到,當呼叫 setState
方法時就會觸發對應 state 定義的 component 的 re-render。然而當我們呼叫 setState
之後如果再次呼叫 setState
呢?這個章節當中我們將會進一步解析關於 setState
方法與 re-render 機制的一些特性。
當我們呼叫 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 的值並不會分別以 1
、2
、3
作為新值依序進行三次 re-render,而是會從 0
在只經過一次 re-render 後直接被更新成 3
:
這種「一個事件中多次呼叫 setState
時,會自動依序合併試算 state 的目標更新結果,並只統一呼叫一次 re-render 來完成畫面更新」,藉由減少不必要的 re-render 來提升效能的機制,就被稱作「batch update」或是「automatic batching」。
setState
交叉連續呼叫也會支援自動 batching這裡要注意的是,自動 batching 並不是只有在連續呼叫同一種 state 的 setState
方法時才會生效,而是任何 state 所對應的 setState
方法混著互叫時都可以支援。
例如以下範例中,component 同時有 count
與 name
兩種 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 之前的版本中 (≤ 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。
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 的資料流概念。
在經過快要一年的努力後,本系列文的實體書版本推出了~其中新增並補充了許多鐵人賽版本中沒有的脈絡與細節,並以全彩印刷拉滿視覺上的閱讀體驗,現正熱銷中!有興趣的話歡迎參考看看,也歡迎分享給其他有接觸前端的朋友們,非常感謝大家~
《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
哪如果我要在這次onclick 就點擊到顯示正確的值 這樣要怎麼改寫下面這段
const handleClick = () => {
flushSync(() => {
setCount(1);
});
console.log(count); //期望值拿到1的話???
};
無法做到,在該次 render 中 count 永遠都會是 setState 前固定值,你只能在下一次的 render 當中才能看到新版的 count 值