setState 到底是同步的,還是異步的
點擊上方關(guān)注 前端技術(shù)江湖,一起學(xué)習(xí),天天進(jìn)步
從一道面試題說起
這是一道變體繁多的面試題,在 BAT 等一線大廠的面試中考察頻率非常高。首先題目會給出一個這樣的 App 組件,在它的內(nèi)部會有如下代碼所示的幾個不同的 setState 操作:
import React from "react";
import "./styles.css";
export default class App extends React.Component{
state = {
count: 0
}
increment = () => {
console.log('increment setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
console.log('increment setState后的count', this.state.count)
}
triple = () => {
console.log('triple setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
console.log('triple setState后的count', this.state.count)
}
reduce = () => {
setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
},0);
}
render(){
return <div>
<button onClick={this.increment}>點我增加</button>
<button onClick={this.triple}>點我增加三倍</button>
<button onClick={this.reduce}>點我減少</button>
</div>
}
}
接著我把組件掛載到 DOM 上:
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
rootElement
);
此時瀏覽器里渲染出來的是如下圖所示的三個按鈕:

此時有個問題,若從左到右依次點擊每個按鈕,控制臺的輸出會是什么樣的?讀到這里,建議你先暫停 1 分鐘在腦子里跑一下代碼,看看和下圖實際運行出來的結(jié)果是否有出入。

如果你是一個熟手 React 開發(fā),那么 increment 這個方法的輸出結(jié)果想必難不倒你——正如許許多多的 React 入門教學(xué)所聲稱的那樣,“setState 是一個異步的方法”,這意味著當(dāng)我們執(zhí)行完 setState 后,state 本身并不會立刻發(fā)生改變。因此緊跟在 setState 后面輸出的 state 值,仍然會維持在它的初始狀態(tài)(0)。在同步代碼執(zhí)行完畢后的某個“神奇時刻”,state 才會“恰恰好”地增加到 1。
但這個“神奇時刻”到底何時發(fā)生,所謂的“恰恰好”又如何界定呢?如果你對這個問題搞不太清楚,那么 triple 方法的輸出對你來說就會有一定的迷惑性——setState 一次不好使, setState 三次也沒用,state 到底是在哪個環(huán)節(jié)發(fā)生了變化呢?
帶著這樣的困惑,你決定先拋開一切去看看 reduce 方法里是什么光景,結(jié)果更令人大跌眼鏡,reduce 方法里的 setState 竟然是同步更新的!這......到底是我們初學(xué) React 時拿到了錯誤的基礎(chǔ)教程,還是電腦壞了?
要想理解眼前發(fā)生的這魔幻的一切,我們還得從 setState 的工作機(jī)制里去找線索。
異步的動機(jī)和原理——批量更新的藝術(shù)
我們首先要認(rèn)知的一個問題:在 setState 調(diào)用之后,都發(fā)生了哪些事情?你可能會更傾向于站在生命周期的角度去思考這個問題,得出一個如下圖所示的結(jié)論:

從圖上我們可以看出,一個完整的更新流程,涉及了包括 re-render(重渲染) 在內(nèi)的多個步驟。re-render 本身涉及對 DOM 的操作,它會帶來較大的性能開銷。假如說“一次 setState 就觸發(fā)一個完整的更新流程”這個結(jié)論成立,那么每一次 setState 的調(diào)用都會觸發(fā)一次 re-render,我們的視圖很可能沒刷新幾次就卡死了。這個過程如我們下面代碼中的箭頭流程圖所示:
this.setState({
count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
this.setState({
count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
this.setState({
count: this.state.count + 1 ===> shouldComponentUpdate->componentWillUpdate->render->componentDidUpdate
});
事實上,這正是 setState 異步的一個重要的動機(jī)——避免頻繁的 re-render。
在實際的 React 運行時中,setState 異步的實現(xiàn)方式有點類似于 Vue 的 $nextTick 和瀏覽器里的 Event-Loop:每來一個 setState,就把它塞進(jìn)一個隊列里“攢起來”。等時機(jī)成熟,再把“攢起來”的 state 結(jié)果做合并,最后只針對最新的 state 值走一次更新流程。這個過程,叫作“批量更新”,批量更新的過程正如下面代碼中的箭頭流程圖所示:
this.setState({
count: this.state.count + 1 ===> 入隊,[count+1的任務(wù)]
});
this.setState({
count: this.state.count + 1 ===> 入隊,[count+1的任務(wù),count+1的任務(wù)]
});
this.setState({
count: this.state.count + 1 ===> 入隊, [count+1的任務(wù),count+1的任務(wù), count+1的任務(wù)]
});
↓
合并 state,[count+1的任務(wù)]
↓
執(zhí)行 count+1的任務(wù)
值得注意的是,只要我們的同步代碼還在執(zhí)行,“攢起來”這個動作就不會停止。(注:這里之所以多次
+1最終只有一次生效,是因為在同一個方法中多次 setState 的合并動作不是單純地將更新累加。比如這里對于相同屬性的設(shè)置,React 只會為其保留最后一次的更新)。因此就算我們在 React 中寫了這樣一個 100 次的 setState 循環(huán):
test = () => {
console.log('循環(huán)100次 setState前的count', this.state.count)
for(let i=0;i<100;i++) {
this.setState({
count: this.state.count + 1
})
}
console.log('循環(huán)100次 setState后的count', this.state.count)
}
也只是會增加 state 任務(wù)入隊的次數(shù),并不會帶來頻繁的 re-render。當(dāng) 100 次調(diào)用結(jié)束后,僅僅是
state的任務(wù)隊列內(nèi)容發(fā)生了變化,state本身并不會立刻改變:
“同步現(xiàn)象”背后的故事:從源碼角度看 setState 工作流
讀到這里,相信你對異步這回事多少有些眉目了。接下來我們就要重點理解剛剛代碼里最詭異的一部分——setState 的同步現(xiàn)象:
reduce = () => {
setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
},0);
}
從題目上看,setState 似乎是在 setTimeout 函數(shù)的“保護(hù)”之下,才有了同步這一“特異功能”。事實也的確如此,假如我們把 setTimeout 摘掉,setState前后的 console 表現(xiàn)將會與 increment 方法中無異:
reduce = () => {
// setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
// },0);
}
點擊后的輸出結(jié)果如下圖所示:

現(xiàn)在問題就變得清晰多了:為什么 setTimeout 可以將 setState 的執(zhí)行順序從異步變?yōu)橥?/code>?
這里我先給出一個結(jié)論:
并不是 setTimeout 改變了 setState,而是 setTimeout 幫助 setState “逃脫”了 React 對它的管控。只要是在 React 管控下的 setState,一定是異步的。
接下來我們就從 React 源碼里,去尋求佐證這個結(jié)論的線索。
時下雖然市場里的 React 16、React 17 十分火熱,但就 setState 這塊知識來說,React 15 仍然是最佳的學(xué)習(xí)素材。因此下文所有涉及源碼的分析,都會圍繞 React 15 展開。關(guān)于 React 16 之后 Fiber 機(jī)制給 setState 帶來的改變,不在本講的討論范圍內(nèi)
解讀 setState 工作流
我們閱讀任何框架的源碼,都應(yīng)該帶著問題、帶著目的去讀。React 中對于功能的拆分是比較細(xì)致的,setState 這部分涉及了多個方法。為了方便你理解,我這里先把主流程提取為一張大圖:

接下來我們就沿著這個流程,逐個在源碼中對號入座。首先是 setState 入口函數(shù):
ReactComponent.prototype.setState = function (partialState, callback) {
this.updater.enqueueSetState(this, partialState);
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState');
}
};
入口函數(shù)在這里就是充當(dāng)一個分發(fā)器的角色,根據(jù)入?yún)⒌牟煌瑢⑵浞职l(fā)到不同的功能函數(shù)中去。這里我們以對象形式的入?yún)槔?,可以看到它直接調(diào)用了 this.updater.enqueueSetState 這個方法:
enqueueSetState: function (publicInstance, partialState) {
// 根據(jù) this 拿到對應(yīng)的組件實例
var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
// 這個 queue 對應(yīng)的就是一個組件實例的 state 數(shù)組
var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
queue.push(partialState);
// enqueueUpdate 用來處理當(dāng)前的組件實例
enqueueUpdate(internalInstance);
}
這里我總結(jié)一下,enqueueSetState 做了兩件事:
將新的 state放進(jìn)組件的狀態(tài)隊列里;用 enqueueUpdate來處理將要更新的實例對象
繼續(xù)往下走,看看 enqueueUpdate 做了什么:
function enqueueUpdate(component) {
ensureInjected();
// 注意這一句是問題的關(guān)鍵,isBatchingUpdates標(biāo)識著當(dāng)前是否處于批量創(chuàng)建/更新組件的階段
if (!batchingStrategy.isBatchingUpdates) {
// 若當(dāng)前沒有處于批量創(chuàng)建/更新組件的階段,則立即更新組件
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
// 否則,先把組件塞入 dirtyComponents 隊列里,讓它“再等等”
dirtyComponents.push(component);
if (component._updateBatchNumber == null) {
component._updateBatchNumber = updateBatchNumber + 1;
}
}
這個 enqueueUpdate 非常有嚼頭,它引出了一個關(guān)鍵的對象——batchingStrategy,該對象所具備的isBatchingUpdates屬性直接決定了當(dāng)下是要走更新流程,還是應(yīng)該排隊等待;其中的batchedUpdates 方法更是能夠直接發(fā)起更新流程。由此我們可以大膽推測,batchingStrategy 或許正是 React 內(nèi)部專門用于管控批量更新的對象。
接下來,我們就一起來研究研究這個 batchingStrategy。
/**
* batchingStrategy源碼
**/
var ReactDefaultBatchingStrategy = {
// 全局唯一的鎖標(biāo)識
isBatchingUpdates: false,
// 發(fā)起更新動作的方法
batchedUpdates: function(callback, a, b, c, d, e) {
// 緩存鎖變量
var alreadyBatchingStrategy = ReactDefaultBatchingStrategy.isBatchingUpdates
// 把鎖“鎖上”
ReactDefaultBatchingStrategy.isBatchingUpdates = true
if (alreadyBatchingStrategy) {
callback(a, b, c, d, e)
} else {
// 啟動事務(wù),將 callback 放進(jìn)事務(wù)里執(zhí)行
transaction.perform(callback, null, a, b, c, d, e)
}
}
}
batchingStrategy 對象并不復(fù)雜,你可以理解為它是一個“鎖管理器”。
這里的“鎖”,是指 React 全局唯一的 isBatchingUpdates 變量,isBatchingUpdates 的初始值是 false,意味著“當(dāng)前并未進(jìn)行任何批量更新操作”。每當(dāng) React 調(diào)用 batchedUpdate 去執(zhí)行更新動作時,會先把這個鎖給“鎖上”(置為 true),表明“現(xiàn)在正處于批量更新過程中”。當(dāng)鎖被“鎖上”的時候,任何需要更新的組件都只能暫時進(jìn)入 dirtyComponents 里排隊等候下一次的批量更新,而不能隨意“插隊”。此處體現(xiàn)的“任務(wù)鎖”的思想,是 React 面對大量狀態(tài)仍然能夠?qū)崿F(xiàn)有序分批處理的基石。
理解了批量更新整體的管理機(jī)制,還需要注意 batchedUpdates 中,有一個引人注目的調(diào)用:
transaction.perform(callback, null, a, b, c, d, e)
這行代碼為我們引出了一個更為硬核的概念——React 中的 Transaction(事務(wù))機(jī)制。
理解 React 中的 Transaction(事務(wù)) 機(jī)制
Transaction 在 React 源碼中的分布可以說非常廣泛。如果你在 Debug React 項目的過程中,發(fā)現(xiàn)函數(shù)調(diào)用棧中出現(xiàn)了 initialize、perform、close、closeAll 或者 notifyAll 這樣的方法名,那么很可能你當(dāng)前就處于一個 Trasaction 中
Transaction 在 React 源碼中表現(xiàn)為一個核心類,React 官方曾經(jīng)這樣描述它:Transaction 是創(chuàng)建一個黑盒,該黑盒能夠封裝任何的方法。因此,那些需要在函數(shù)運行前、后運行的方法可以通過此方法封裝(即使函數(shù)運行中有異常拋出,這些固定的方法仍可運行),實例化 Transaction 時只需提供相關(guān)的方法即可。
這段話初讀有點拗口,這里我推薦你結(jié)合 React 源碼中的一段針對 Transaction 的注釋來理解它:
* <pre>
* wrappers (injected at creation time)
* + +
* | |
* +-----------------|--------|--------------+
* | v | |
* | +---------------+ | |
* | +--| wrapper1 |---|----+ |
* | | +---------------+ v | |
* | | +-------------+ | |
* | | +----| wrapper2 |--------+ |
* | | | +-------------+ | | |
* | | | | | |
* | v v v v | wrapper
* | +---+ +---+ +---------+ +---+ +---+ | invariants
* perform(anyMethod) | | | | | | | | | | | | maintained
* +----------------->|-|---|-|---|-->|anyMethod|---|---|-|---|-|-------->
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | | | | | | | | | | | |
* | +---+ +---+ +---------+ +---+ +---+ |
* | initialize close |
* +-----------------------------------------+
* </pre>
*
說白了,Transaction 就像是一個“殼子”,它首先會將目標(biāo)函數(shù)用 wrapper(一組 initialize 及 close 方法稱為一個 wrapper) 封裝起來,同時需要使用 Transaction 類暴露的 perform 方法去執(zhí)行它。如上面的注釋所示,在 anyMethod 執(zhí)行之前,perform 會先執(zhí)行所有 wrapper 的 initialize 方法,執(zhí)行完后,再執(zhí)行所有 wrapper 的 close 方法。這就是 React 中的事務(wù)機(jī)制。
“同步現(xiàn)象”的本質(zhì)
下面結(jié)合對事務(wù)機(jī)制的理解,我們繼續(xù)來看在 ReactDefaultBatchingStrategy 這個對象。ReactDefaultBatchingStrategy 其實就是一個批量更新策略事務(wù),它的 wrapper 有兩個:FLUSH_BATCHED_UPDATES 和 RESET_BATCHED_UPDATES
var RESET_BATCHED_UPDATES = {
initialize: emptyFunction,
close: function () {
ReactDefaultBatchingStrategy.isBatchingUpdates = false;
}
};
var FLUSH_BATCHED_UPDATES = {
initialize: emptyFunction,
close: ReactUpdates.flushBatchedUpdates.bind(ReactUpdates)
};
var TRANSACTION_WRAPPERS = [FLUSH_BATCHED_UPDATES, RESET_BATCHED_UPDATES];
我們把這兩個 wrapper 套進(jìn) Transaction 的執(zhí)行機(jī)制里,不難得出一個這樣的流程:

到這里,相信你對 isBatchingUpdates 管控下的批量更新機(jī)制已經(jīng)了然于胸。但是 setState 為何會表現(xiàn)同步這個問題,似乎還是沒有從當(dāng)前展示出來的源碼里得到根本上的回答。這是因為 batchingUpdates 這個方法,不僅僅會在 setState 之后才被調(diào)用。若我們在 React 源碼中全局搜索 batchingUpdates,會發(fā)現(xiàn)調(diào)用它的地方很多,但與更新流有關(guān)的只有這兩個地方:
// ReactMount.js
_renderNewRootComponent: function( nextElement, container, shouldReuseMarkup, context ) {
// 實例化組件
var componentInstance = instantiateReactComponent(nextElement);
// 初始渲染直接調(diào)用 batchedUpdates 進(jìn)行同步渲染
ReactUpdates.batchedUpdates(
batchedMountComponentIntoNode,
componentInstance,
container,
shouldReuseMarkup,
context
);
...
}
這段代碼是在首次渲染組件時會執(zhí)行的一個方法,我們看到它內(nèi)部調(diào)用了一次 batchedUpdates,這是因為在組件的渲染過程中,會按照順序調(diào)用各個生命周期函數(shù)。開發(fā)者很有可能在聲明周期函數(shù)中調(diào)用 setState。因此,我們需要通過開啟 batch 來確保所有的更新都能夠進(jìn)入 dirtyComponents 里去,進(jìn)而確保初始渲染流程中所有的 setState 都是生效的。
下面代碼是 React 事件系統(tǒng)的一部分。當(dāng)我們在組件上綁定了事件之后,事件中也有可能會觸發(fā) setState。為了確保每一次 setState 都有效,React 同樣會在此處手動開啟批量更新。
// ReactEventListener.js
dispatchEvent: function (topLevelType, nativeEvent) {
...
try {
// 處理事件
ReactUpdates.batchedUpdates(handleTopLevelImpl, bookKeeping);
} finally {
TopLevelCallbackBookKeeping.release(bookKeeping);
}
}
話說到這里,一切都變得明朗了起來:isBatchingUpdates 這個變量,在 React 的生命周期函數(shù)以及合成事件執(zhí)行前,已經(jīng)被 React 悄悄修改為了 true,這時我們所做的 setState操作自然不會立即生效。當(dāng)函數(shù)執(zhí)行完畢后,事務(wù)的 close 方法會再把 isBatchingUpdates 改為 false。
以開頭示例中的 increment 方法為例,整個過程像是這樣:
increment = () => {
// 進(jìn)來先鎖上
isBatchingUpdates = true
console.log('increment setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
console.log('increment setState后的count', this.state.count)
// 執(zhí)行完函數(shù)再放開
isBatchingUpdates = false
}
很明顯,在 isBatchingUpdates 的約束下,setState 只能是異步的。而當(dāng) setTimeout 從中作祟時,事情就會發(fā)生一點點變化
reduce = () => {
// 進(jìn)來先鎖上
isBatchingUpdates = true
setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
},0);
// 執(zhí)行完函數(shù)再放開
isBatchingUpdates = false
}
會發(fā)現(xiàn),咱們開頭鎖上的那個 isBatchingUpdates,對 setTimeout 內(nèi)部的執(zhí)行邏輯完全沒有約束力。因為 isBatchingUpdates是在同步代碼中變化的,而 setTimeout 的邏輯是異步執(zhí)行的。當(dāng) this.setState 調(diào)用真正發(fā)生的時候,isBatchingUpdates 早已經(jīng)被重置為了 false,這就使得當(dāng)前場景下的 setState 具備了立刻發(fā)起同步更新的能力。所以咱們前面說的沒錯—— setState 并不是具備同步這種特性,只是在特定的情境下,它會從 React 的異步管控中“逃脫”掉。
總結(jié)
setState 并不是單純同步/異步的,它的表現(xiàn)會因調(diào)用場景的不同而不同:在 React 鉤子函數(shù)及合成事件中,它表現(xiàn)為異步;而在 setTimeout、setInterval 等函數(shù)中,包括在 DOM 原生事件中,它都表現(xiàn)為同步。這種差異,本質(zhì)上是由 React 事務(wù)機(jī)制和批量更新機(jī)制的工作方式來決定的。
The End
歡迎自薦投稿到《前端技術(shù)江湖》,如果你覺得這篇內(nèi)容對你挺有啟發(fā),記得點個 「在看」哦
點個『在看』支持下 
