關於 setState
setState 的更新是同步還是異步,一直是人們津津樂道的話題。不過,實際上如果我們需要用到更新后的狀態值,並不需要強依賴其同步/異步更新機制。在類組件中,我們可以通過this.setState的第二參數、componentDidMount、componentDidUpdate等手段來取得更新后的值;而在函數式組件中,則可以通過useEffect來獲取更新后的狀態。所以這個問題,其實有點無聊。
不過,既然大家都這么樂於討論,今天我們就系統地梳理一下這個問題,主要分為兩方面來說:
- 類組件(
class-component)的更新機制 - 函數式組件(
function-component)的更新機制
類組件中的 this.setState
在類組件中,這個問題的答案是多樣的,首先拋第一個結論:
- 在
legacy模式中,更新可能為同步,也可能為異步; - 在
concurrent模式中,一定是異步。
問題一、legacy 模式和 concurrent 模式是什么鬼?
-
通過
ReactDOM.render(<App />, rootNode)方式創建應用,則為 legacy 模式,這也是create-react-app目前采用的默認模式; -
通過
ReactDOM.unstable_createRoot(rootNode).render(<App />)方式創建的應用,則為concurrent模式,這個模式目前只是一個實驗階段的產物,還不成熟。
legacy 模式下可能同步,也可能異步?
是的,這不是玄學,我們來先拋出結論,再來逐步解釋它。
- 當直接調用時
this.setState時,為異步更新; - 當在異步函數的回調中調用
this.setState,則為同步更新; - 當放在自定義 DOM 事件的處理函數中時,也是同步更新。
實驗代碼如下:
class StateDemo extends React.Component {
constructor(props) {
super(props)
this.state = {
count: 0
}
}
render() {
return <div>
<p>{this.state.count}</p>
<button onClick={this.increase}>累加</button>
</div>
}
increase = () => {
this.setState({
count: this.state.count + 1
})
// 異步的,拿不到最新值
console.log('count', this.state.count)
// setTimeout 中 setState 是同步的
setTimeout(() => {
this.setState({
count: this.state.count + 1
})
// 同步的,可以拿到
console.log('count in setTimeout', this.state.count)
}, 0)
}
bodyClickHandler = () => {
this.setState({
count: this.state.count + 1
})
// 可以取到最新值
console.log('count in body event', this.state.count)
}
componentDidMount() {
// 自己定義的 DOM 事件,setState 是同步的
document.body.addEventListener('click', this.bodyClickHandler)
}
componentWillUnmount() {
// 及時銷毀自定義 DOM 事件
document.body.removeEventListener('click', this.bodyClickHandler)
}
}
要解答上述現象,就必須了解 setState 的主流程,以及 react 中的 batchUpdate 機制。
首先我們來看看 setState 的主流程:
- 調用
this.setState(newState); newState會存入 pending 隊列;
3,判斷是不是batchUpdate;
4,如果是batchUpdate,則將組件先保存在所謂的臟組件dirtyComponents中;如果不是batchUpdate,那么就遍歷所有的臟組件,並更新它們。
由此我們可以判定:所謂的異步更新,都命中了batchUpdate,先保存在臟組件中就完事;而同步更新,總是會去更新所有的臟組件。
非常有意思,看來是否命中batchUpdate是關鍵。問題也隨之而來了,為啥直接調用就能命中batchUpdate,而放在異步回調里或者自定義 DOM 事件中就命中不了呢?
這就涉及到一個很有意思的知識點:react 中函數的調用模式。對於剛剛的 increase 函數,還有一些我們看不到的東西,現在我們通過魔法讓其顯現出來:
increase = () => {
// 開始:默認處於bashUpdate
// isBatchingUpdates = true
this.setState({
count: this.state.count + 1
})
console.log('count', this.state.count)
// 結束
// isBatchingUpdates = false
}
increase = () => {
// 開始:默認處於bashUpdate
// isBatchingUpdates = true
setTimeout(() => {
// 此時isBatchingUpdates已經設置為了false
this.setState({
count: this.state.count + 1
})
console.log('count in setTimeout', this.state.count)
}, 0)
// 結束
// isBatchingUpdates = false
}
當 react 執行我們所書寫的函數時,會默認在首位設置isBatchingUpdates變量。看到其中的差異了嗎?當 setTimeout 執行其回調時,isBatchingUpdates早已經在同步代碼的末尾被置為false了,所以沒命中batchUpdate。
那自定義 DOM 事件又是怎么回事?代碼依然如下:
componentDidMount() {
// 開始:默認處於bashUpdate
// isBatchingUpdates = true
document.body.addEventListener("click", () => {
// 在回調函數里面,當點擊事件觸發的時候,isBatchingUpdates早就已經設為false了
this.setState({
count: this.state.count + 1,
});
console.log("count in body event", this.state.count); // 可以取到最新值。
});
// 結束
// isBatchingUpdates = false
}
我們可以看到,當componentDidMount跑完時,isBatchingUpdates已經設置為false了,而點擊事件后來觸發,並調用回調函數時,取得的isBatchingUpdates當然也是false,不會命中batchUpdate機制。
總結:
this.setState是同步還是異步,關鍵就是看能否命中batchUpdate機制- 能不能命中,就是看
isBatchingUpdates是true還是false - 能命中
batchUpdate的場景包括:生命周期和其調用函數、React中注冊的事件和其調用函數。總之,是React可以“管理”的入口,關鍵是“入口”。
這里要注意一點:React去加isBatchingUpdate的行為不是針對“函數”,而是針對“入口”。比如setTimeout、setInterval、自定義DOM事件的回調等,這些都是React“管不到”的入口,所以不會去其首尾設置isBatchingUpdates變量。
concurrent 模式一定是異步更新
因為這個東西只在實驗階段,所以要開啟 concurrent 模式,同樣需要將 react 升級為實驗版本,安裝如下依賴:
npm install react@experimental react-dom@experimental
其他代碼不用變,只更改 index 文件如下:
- ReactDOM.render(<App />, document.getElementById('root'));
+ ReactDOM.unstable_createRoot(document.getElementById('root')).render(<App />);
則可以發現:其更新都是異步的,在任何情況下都是如此。
關於函數式組件中 useState 的 setter
在函數式組件中,我們會這樣定義狀態:
const [count, setCount] = useState(0)
這時候,我們發現當我們無論在同步函數還是在異步回調中調用 setCount 時,打印出來的 count 都是舊值,這時候我們會說:setCount 是異步的。
const [count, setCount] = useState(0);
// 直接調用
const handleStrightUpdate = () => {
setCount(1);
console.log(count); // 0
};
// 放在setTimeout回調中
const handleSetTimeoutUpdate = () => {
setTimeout(() => {
setCount(1);
console.log(count); // 0
});
};
setCount 是異步的,這確實沒錯,但是產生上述現象的原因不只是異步更新這么簡單。原因主要有以下兩點:
1,調用 setCount 時,會做合並處理,異步更新該函數式組件對應的 hooks 鏈表里面的值,然后觸發重渲染(re-renders),從這個角度上來說,setCount確實是一個異步操作;
2,函數式的capture-value特性決定了console.log(count)語句打印的始終是一個只存在於當前幀的常量,所以就算無論 setCount 是不是同步的,這里都會打印出舊值。
