關於 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 是不是同步的,這裡都會列印出舊值。