【面試題】1085- setState 到底是同步的,還是異步的

從一道面試題說起
這是一道變體繁多的面試題,在 BAT 等一線大廠的面試中考察頻率非常高。首先題目會給出一個(gè)這樣的 App 組件,在它的內(nèi)部會有如下代碼所示的幾個(gè)不同的 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}>點(diǎn)我增加</button>
<button onClick={this.triple}>點(diǎn)我增加三倍</button>
<button onClick={this.reduce}>點(diǎn)我減少</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
);
此時(shí)瀏覽器里渲染出來的是如下圖所示的三個(gè)按鈕:

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

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

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

現(xiàn)在問題就變得清晰多了:為什么 setTimeout 可以將 setState 的執(zhí)行順序從異步變?yōu)橥?/code>?
這里我先給出一個(gè)結(jié)論:
并不是 setTimeout 改變了 setState,而是 setTimeout 幫助 setState “逃脫”了 React 對它的管控。只要是在 React 管控下的 setState,一定是異步的。
接下來我們就從 React 源碼里,去尋求佐證這個(gè)結(jié)論的線索。
時(shí)下雖然市場里的 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 這部分涉及了多個(gè)方法。為了方便你理解,我這里先把主流程提取為一張大圖:

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

到這里,相信你對 isBatchingUpdates 管控下的批量更新機(jī)制已經(jīng)了然于胸。但是 setState 為何會表現(xiàn)同步這個(gè)問題,似乎還是沒有從當(dāng)前展示出來的源碼里得到根本上的回答。這是因?yàn)?nbsp;batchingUpdates 這個(gè)方法,不僅僅會在 setState 之后才被調(diào)用。若我們在 React 源碼中全局搜索 batchingUpdates,會發(fā)現(xiàn)調(diào)用它的地方很多,但與更新流有關(guān)的只有這兩個(gè)地方:
// ReactMount.js
_renderNewRootComponent: function( nextElement, container, shouldReuseMarkup, context ) {
// 實(shí)例化組件
var componentInstance = instantiateReactComponent(nextElement);
// 初始渲染直接調(diào)用 batchedUpdates 進(jìn)行同步渲染
ReactUpdates.batchedUpdates(
batchedMountComponentIntoNode,
componentInstance,
container,
shouldReuseMarkup,
context
);
...
}
這段代碼是在首次渲染組件時(shí)會執(zhí)行的一個(gè)方法,我們看到它內(nèi)部調(diào)用了一次 batchedUpdates,這是因?yàn)樵诮M件的渲染過程中,會按照順序調(diào)用各個(gè)生命周期函數(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 這個(gè)變量,在 React 的生命周期函數(shù)以及合成事件執(zhí)行前,已經(jīng)被 React 悄悄修改為了 true,這時(shí)我們所做的 setState操作自然不會立即生效。當(dāng)函數(shù)執(zhí)行完畢后,事務(wù)的 close 方法會再把 isBatchingUpdates 改為 false。
以開頭示例中的 increment 方法為例,整個(gè)過程像是這樣:
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 從中作祟時(shí),事情就會發(fā)生一點(diǎn)點(diǎn)變化
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),咱們開頭鎖上的那個(gè) isBatchingUpdates,對 setTimeout 內(nèi)部的執(zhí)行邏輯完全沒有約束力。因?yàn)?nbsp;isBatchingUpdates是在同步代碼中變化的,而 setTimeout 的邏輯是異步執(zhí)行的。當(dāng) this.setState 調(diào)用真正發(fā)生的時(shí)候,isBatchingUpdates 早已經(jīng)被重置為了 false,這就使得當(dāng)前場景下的 setState 具備了立刻發(fā)起同步更新的能力。所以咱們前面說的沒錯(cuò)—— setState 并不是具備同步這種特性,只是在特定的情境下,它會從 React 的異步管控中“逃脫”掉。
總結(jié)
setState 并不是單純同步/異步的,它的表現(xiàn)會因調(diào)用場景的不同而不同:在 React 鉤子函數(shù)及合成事件中,它表現(xiàn)為異步;而在 setTimeout、setInterval 等函數(shù)中,包括在 DOM 原生事件中,它都表現(xiàn)為同步。這種差異,本質(zhì)上是由 React 事務(wù)機(jī)制和批量更新機(jī)制的工作方式來決定的。

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
