面試官:“react中
setState
是同步的還是非同步?”
我:“非同步的,setState
不能立馬拿到結果。”
面試官:“那什麼場景下是非同步的,可不可能是同步,什麼場景下又是同步的?”
我:“……”
setState
真的是非同步的嗎?
這兩天自己簡單的看了下 setState
的部分實現程式碼,在這邊給到大家一個自己個人的見解,可能文字或圖片較多,沒耐心的同學可以直接跳過看總結(原始碼版本是16.4.1)。
看之前,為了方便理解和簡化流程,我們預設react內部程式碼執行到performWork
、performWorkOnRoot
、performSyncWork
、performAsyncWork
這四個方法的時候,就是react去update更新並且作用到UI上。
一、合成事件中的setState
首先得了解一下什麼是合成事件,react為了解決跨平臺,相容性問題,自己封裝了一套事件機制,代理了原生的事件,像在jsx
中常見的onClick
、onChange
這些都是合成事件。
class App extends Component {
state = { val: 0 }
increment = () => {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val) // 輸出的是更新前的val --> 0
}
render() {
return (
<div onClick={this.increment}>
{`Counter is: ${this.state.val}`}
</div>
)
}
}
複製程式碼
合成事件中的setState
寫法比較常見,點選事件裡去改變 this.state.val
的狀態值,在 increment
事件中打個斷點可以看到呼叫棧,這裡我貼一張自己畫的流程圖:
從 dispatchInteractiveEvent
到 callCallBack
為止,都是對合成事件的處理和執行,從 setState
到 requestWork
是呼叫 this.setState
的邏輯,這邊主要看下 requestWork
這個函式(從 dispatchEvent
到 requestWork
的呼叫棧是屬於 interactiveUpdates$1
的 try
程式碼塊,下文會提到)。
function requestWork(root, expirationTime) {
addRootToSchedule(root, expirationTime);
if (isRendering) {
// Prevent reentrancy. Remaining work will be scheduled at the end of
// the currently rendering batch.
return;
}
if (isBatchingUpdates) {
// Flush work at the end of the batch.
if (isUnbatchingUpdates) {
// ...unless we`re inside unbatchedUpdates, in which case we should
// flush it now.
nextFlushedRoot = root;
nextFlushedExpirationTime = Sync;
performWorkOnRoot(root, Sync, false);
}
return;
}
// TODO: Get rid of Sync and use current time?
if (expirationTime === Sync) {
performSyncWork();
} else {
scheduleCallbackWithExpiration(expirationTime);
}
}
複製程式碼
在 requestWork
中有三個if分支,三個分支中有兩個方法 performWorkOnRoot
和 performSyncWork
,就是我們預設的update函式,但是在合成事件中,走的是第二個if分支,第二個分支中有兩個標識 isBatchingUpdates
和 isUnbatchingUpdates
兩個初始值都為 false
,但是在 interactiveUpdates$1
中會把 isBatchingUpdates
設為 true
,下面就是 interactiveUpdates$1
的程式碼:
function interactiveUpdates$1(fn, a, b) {
if (isBatchingInteractiveUpdates) {
return fn(a, b);
}
// If there are any pending interactive updates, synchronously flush them.
// This needs to happen before we read any handlers, because the effect of
// the previous event may influence which handlers are called during
// this event.
if (!isBatchingUpdates && !isRendering && lowestPendingInteractiveExpirationTime !== NoWork) {
// Synchronously flush pending interactive updates.
performWork(lowestPendingInteractiveExpirationTime, false, null);
lowestPendingInteractiveExpirationTime = NoWork;
}
var previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates;
var previousIsBatchingUpdates = isBatchingUpdates;
isBatchingInteractiveUpdates = true;
isBatchingUpdates = true; // 把requestWork中的isBatchingUpdates標識改為true
try {
return fn(a, b);
} finally {
isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates;
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performSyncWork();
}
}
}
複製程式碼
在這個方法中把 isBatchingUpdates
設為了 true
,導致在 requestWork
方法中, isBatchingUpdates
為 true
,但是 isUnbatchingUpdates
是 false
,而被直接return了。
那return完的邏輯回到哪裡呢,最終正是回到了 interactiveUpdates
這個方法,仔細看一眼,這個方法裡面有個try finally語法,前端同學這個其實是用的比較少的,簡單的說就是會先執行 try
程式碼塊中的語句,然後再執行 finally
中的程式碼,而 fn(a, b)
是在try程式碼塊中,剛才說到在 requestWork
中被return掉的也就是這個fn(上文提到的 從dispatchEvent
到 requestWork
的一整個呼叫棧)。
所以當你在 increment
中呼叫 setState
之後去console.log的時候,是屬於 try
程式碼塊中的執行,但是由於是合成事件,try程式碼塊執行完state並沒有更新,所以你輸入的結果是更新前的 state
值,這就導致了所謂的”非同步”,但是當你的try程式碼塊執行完的時候(也就是你的increment合成事件),這個時候會去執行 finally
裡的程式碼,在 finally
中執行了 performSyncWork
方法,這個時候才會去更新你的 state
並且渲染到UI上。
二、生命週期函式中的setState
class App extends Component {
state = { val: 0 }
componentDidMount() {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val) // 輸出的還是更新前的值 --> 0
}
render() {
return (
<div>
{`Counter is: ${this.state.val}`}
</div>
)
}
}
複製程式碼
鉤子函式中setState的呼叫棧:
其實還是和合成事件一樣,當 componentDidmount
執行的時候,react內部並沒有更新,執行完componentDidmount
後才去 commitUpdateQueue
更新。這就導致你在 componentDidmount
中 setState
完去console.log拿的結果還是更新前的值。
三、原生事件中的setState
class App extends Component {
state = { val: 0 }
changeValue = () => {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val) // 輸出的是更新後的值 --> 1
}
componentDidMount() {
document.body.addEventListener(`click`, this.changeValue, false)
}
render() {
return (
<div>
{`Counter is: ${this.state.val}`}
</div>
)
}
}
複製程式碼
原生事件是指非react合成事件,原生自帶的事件監聽 addEventListener
,或者也可以用原生js、jq直接 document.querySelector().onclick
這種繫結事件的形式都屬於原生事件。
原生事件的呼叫棧就比較簡單了,因為沒有走合成事件的那一大堆,直接觸發click事件,到 requestWork
,在requestWork
裡由於 expirationTime === Sync
的原因,直接走了 performSyncWork
去更新,並不像合成事件或鉤子函式中被return,所以當你在原生事件中setState後,能同步拿到更新後的state值。
四、setTimeout中的setState
class App extends Component {
state = { val: 0 }
componentDidMount() {
setTimeout(_ => {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val) // 輸出更新後的值 --> 1
}, 0)
}
render() {
return (
<div>
{`Counter is: ${this.state.val}`}
</div>
)
}
}
複製程式碼
在 setTimeout
中去 setState
並不算是一個單獨的場景,它是隨著你外層去決定的,因為你可以在合成事件中 setTimeout
,可以在鉤子函式中 setTimeout
,也可以在原生事件setTimeout
,但是不管是哪個場景下,基於event loop的模型下, setTimeout
中裡去 setState
總能拿到最新的state值。
舉個例子,比如之前的合成事件,由於你是 setTimeout(_ => { this.setState()}, 0)
是在 try
程式碼塊中,當你 try
程式碼塊執行到 setTimeout
的時候,把它丟到列隊裡,並沒有去執行,而是先執行的 finally
程式碼塊,等 finally
執行完了, isBatchingUpdates
又變為了 false
,導致最後去執行佇列裡的 setState
時候, requestWork
走的是和原生事件一樣的 expirationTime === Sync
if分支,所以表現就會和原生事件一樣,可以同步拿到最新的state值。
五、setState
中的批量更新
class App extends Component {
state = { val: 0 }
batchUpdates = () => {
this.setState({ val: this.state.val + 1 })
this.setState({ val: this.state.val + 1 })
this.setState({ val: this.state.val + 1 })
}
render() {
return (
<div onClick={this.batchUpdates}>
{`Counter is ${this.state.val}`} // 1
</div>
)
}
}
複製程式碼
上面的結果最終是1,在 setState
的時候react內部會建立一個 updateQueue
,通過 firstUpdate
、 lastUpdate
、 lastUpdate.next
去維護一個更新的佇列,在最終的 performWork
中,相同的key會被覆蓋,只會對最後一次的 setState
進行更新,下面是部分實現程式碼:
function createUpdateQueue(baseState) {
var queue = {
expirationTime: NoWork,
baseState: baseState,
firstUpdate: null,
lastUpdate: null,
firstCapturedUpdate: null,
lastCapturedUpdate: null,
firstEffect: null,
lastEffect: null,
firstCapturedEffect: null,
lastCapturedEffect: null
};
return queue;
}
function appendUpdateToQueue(queue, update, expirationTime) {
// Append the update to the end of the list.
if (queue.lastUpdate === null) {
// Queue is empty
queue.firstUpdate = queue.lastUpdate = update;
} else {
queue.lastUpdate.next = update;
queue.lastUpdate = update;
}
if (queue.expirationTime === NoWork || queue.expirationTime > expirationTime) {
// The incoming update has the earliest expiration of any update in the
// queue. Update the queue`s expiration time.
queue.expirationTime = expirationTime;
}
}
複製程式碼
看個?
class App extends React.Component {
state = { val: 0 }
componentDidMount() {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val)
this.setState({ val: this.state.val + 1 })
console.log(this.state.val)
setTimeout(_ => {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val);
this.setState({ val: this.state.val + 1 })
console.log(this.state.val)
}, 0)
}
render() {
return <div>{this.state.val}</div>
}
}
複製程式碼
結合上面分析的,鉤子函式中的 setState
無法立馬拿到更新後的值,所以前兩次都是輸出0,當執行到 setTimeout
裡的時候,前面兩個state的值已經被更新,由於 setState
批量更新的策略, this.state.val
只對最後一次的生效,為1,而在 setTimmout
中 setState
是可以同步拿到更新結果,所以 setTimeout
中的兩次輸出2,3,最終結果就為 0, 0, 2, 3
。
總結 :
setState
只在合成事件和鉤子函式中是“非同步”的,在原生事件和setTimeout
中都是同步的。setState
的“非同步”並不是說內部由非同步程式碼實現,其實本身執行的過程和程式碼都是同步的,只是合成事件和鉤子函式的呼叫順序在更新之前,導致在合成事件和鉤子函式中沒法立馬拿到更新後的值,形式了所謂的“非同步”,當然可以通過第二個引數 setState(partialState, callback) 中的callback拿到更新後的結果。setState
的批量更新優化也是建立在“非同步”(合成事件、鉤子函式)之上的,在原生事件和setTimeout 中不會批量更新,在“非同步”中如果對同一個值進行多次setState
,setState
的批量更新策略會對其進行覆蓋,取最後一次的執行,如果是同時setState
多個不同的值,在更新時會對其進行合併批量更新。
以上就是我看了部分程式碼後的粗淺理解,對原始碼細節的那塊分析的較少,主要是想讓大家理解setState
在不同的場景,不同的寫法下到底發生了什麼樣的一個過程和結果,希望對大家有幫助,由於是個人的理解和見解,如果哪裡有說的不對的地方,歡迎大家一起指出並討論。
另外,幫朋友打個廣告 :
有好友整理了一波內推崗位,已釋出到github,感興趣的可以聯絡cXE3MjcwNDAxNDE=