在非同步事件中的 re-render
這邊要先感謝這篇 派氏文件 - 元件渲染 的作者 abemscac,因為我在這個 小節 的 re-render 次數一直很不解,最後跑去私訊問作者得到解答。
React 的非同步事件
function fetchSomething() {
return fetch('https://jsonplaceholder.typicode.com/todos/1')
}
function App() {
console.log('[App rerender]')
const [count, setCount] = useState(0)
async function handleClick() {
setCount(1)
await fetchSomething()
setCount(2)
}
return (
<div>
<div>Count: {count}</div>
<button
type="button"
onClick={handleClick}
>
Click
</button>
</div>
)
}
在上面的範例中,可以思考一下 App 元件一共重新渲染了幾次 ?
執行結果
一共是 2 次

( 灰色的 console.log 是 React Strict Mode 的產物 )
我一開始蠻天真的,本來以為 React 18 會因為 batches state updates 的機制讓非同步程式碼也可以僅重新渲染一次,但事實上並不是如此,而且也說明我對 Event Loop 中 microtask 的機制並不是很熟悉。
先將 async / await 轉換成 Promise 釐清一下
根據 MDN await 文件,我們可以將上面程式碼中的 handleClick 方法轉換成 Promise 的形式 :
function handleClick() {
return new Promise(function p1(resolve) {
setCount(1)
resolve(fetchSomething())
}).then(function t1() {
setCount(2)
})
}
為了方便說明,我將 Promise 內的 callback 命名為 p1,.then() 的 callback 命名為 t1。
我們知道在 Promise 裡面的程式碼是同步執行,.then(callback) 裡面的 callback function 才會排定微任務,由此得知,await 後面的表達式會同步執行,以這個例子來說,就是 fetchSomething() 會同步執行。
而 await 之後的程式碼,也就是 setCount(2) 等於被放進 .then(callback) 的 callback function 裡面排定為微任務執行。
所以按下 button 到底發生了什麼事 ?
先附上宏任務及微任務的 event loop 簡化流程圖 :

接著分析一下點擊 button 觸發 handleClick 事件流程 :
function fetchSomething() {
return fetch('https://jsonplaceholder.typicode.com/todos/1')
}
function handleClick() {
return new Promise(function p1(resolve) {
setCount(1)
resolve(fetchSomething())
}).then(function t1() {
setCount(2)
})
}
以下流程中的執行,皆是指將任務 ( task ) 放到 Call Stack 上執行。
- 點擊 button,將
handleClick排至宏任務佇列,此時宏任務佇列為[ handleClick ]。 - 從宏任務佇列拿出
handleClick執行,這個 click event handler 會存在於 Call Stack 上直到p1上所有 task 執行完畢才會移除。 - 執行
p1,接著執行setCount(1),此時 React 的批次更新佇列 ( batch updates queue ) 從原本空的[ ],新增更新任務到佇列[ 取代為 1 ]。 - 執行
fetchSomething(),此時 Promise 狀態為Pending。 - 還沒結束,此時有一個 anonymous function 被推到 Call Stack 上執行,目的是執行 React 批次更新佇列
[ 取代為 1 ],執行完畢後更新 state,觸發 ReactApp元件重新渲染 ( re-render ),注意 React 的重新渲染為 Virtual DOM 的重新繪製,不代表瀏覽器的渲染。 p1皆執行完畢,p1從 Call Stack 中移除,handleClick也從 Call Stack 中移除,此時 Call Stack 為空,微任務佇列為空,觸發瀏覽器渲染一次。- 剛剛
p1裡面的fetchSomething()得到回應,Promise 狀態為Fulfilled,由 JavaScript 引擎將.then(t1)裡面的t1加入微任務佇列等待執行,此時宏任務佇列為[ ]、微任務佇列為[ t1 ]。 - 檢查宏任務佇列為空;檢查微任務佇列
[ t1 ],拿出t1執行。 - 執行
setCount(2),此時 React 的批次更新佇列從原本空的[ ],新增更新任務到佇列[ 取代為 2 ]。 - 執行 React 批次更新佇列的 anonymous function 被推到 Call Stack 上執行,執行完畢後更新 state,觸發 React
App元件重新渲染 ( re-render )。 t1皆執行完畢,t1從 Call Stack 中移除- 檢查微任務佇列為空,觸發瀏覽器渲染一次。
以上就是還原整個事件流程,這邊也附上模擬流程圖 ( 步驟中右上角數字代表上面流程執行的順序編號 ) :
延伸這個範例
我們還原了整個事件的流程,變化一下範例 :
function fetchSomething() {
return fetch('https://jsonplaceholder.typicode.com/todos/1')
}
async function handleClick() {
setCount(1)
await fetchSomething()
setCount(2)
await fetchSomething()
setCount(3)
await fetchSomething()
setCount(4)
await fetchSomething()
setCount(5)
}
有興趣可以拆解步驟還原整個 handleClick 事件流程,現在應該難不倒你了,觸發 handleClick 總共會造成 App 元件重新渲染幾次呢 ?
執行結果
一共是 5 次

因此要在 async 方法中避免元件重新渲染多次,可以這樣 :
function fetchSomething() {
return fetch('https://jsonplaceholder.typicode.com/todos/1')
}
async function handleClick() {
setCount(1)
setCount(2)
setCount(3)
setCount(4)
setCount(5)
// 讓以上的 setState 方法 automatic batching
await fetchSomething()
await fetchSomething()
await fetchSomething()
await fetchSomething()
}
執行結果 :

總結
雖然我不知道這個議題在實務上會不會真的發生,但研究過程也蠻有趣的,讓我又複習了 Event Loop 一遍,不過呢,也許還原整個事件的流程可能有哪些地方有疑慮,如果有讀者發現哪裡不對的話,還請不吝嗇留言糾正我 (-ω-ゞ