你真的理解setState嗎?

虹晨發表於2019-03-04

面試官:“react中setState是同步的還是非同步?”
我:“非同步的,setState不能立馬拿到結果。”

面試官:“那什麼場景下是非同步的,可不可能是同步,什麼場景下又是同步的?”
我:“......”

setState真的是非同步的嗎?

這兩天自己簡單的看了下 setState 的部分實現程式碼,在這邊給到大家一個自己個人的見解,可能文字或圖片較多,沒耐心的同學可以直接跳過看總結(原始碼版本是16.4.1)。

看之前,為了方便理解和簡化流程,我們預設react內部程式碼執行到performWorkperformWorkOnRootperformSyncWorkperformAsyncWork這四個方法的時候,就是react去update更新並且作用到UI上。

一、合成事件中的setState

首先得了解一下什麼是合成事件,react為了解決跨平臺,相容性問題,自己封裝了一套事件機制,代理了原生的事件,像在jsx中常見的onClickonChange這些都是合成事件。

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 事件中打個斷點可以看到呼叫棧,這裡我貼一張自己畫的流程圖:

合成事件中setState的呼叫棧
dispatchInteractiveEventcallCallBack 為止,都是對合成事件的處理和執行,從 setStaterequestWork 是呼叫 this.setState 的邏輯,這邊主要看下 requestWork 這個函式(從 dispatchEventrequestWork 的呼叫棧是屬於 interactiveUpdates$1try 程式碼塊,下文會提到)。

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分支,三個分支中有兩個方法 performWorkOnRootperformSyncWork ,就是我們預設的update函式,但是在合成事件中,走的是第二個if分支,第二個分支中有兩個標識 isBatchingUpdatesisUnbatchingUpdates 兩個初始值都為 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 方法中, isBatchingUpdatestrue ,但是 isUnbatchingUpdatesfalse ,而被直接return了。

那return完的邏輯回到哪裡呢,最終正是回到了 interactiveUpdates 這個方法,仔細看一眼,這個方法裡面有個try finally語法,前端同學這個其實是用的比較少的,簡單的說就是會先執行 try 程式碼塊中的語句,然後再執行 finally 中的程式碼,而 fn(a, b) 是在try程式碼塊中,剛才說到在 requestWork 中被return掉的也就是這個fn(上文提到的 從dispatchEventrequestWork 的一整個呼叫棧)。

所以當你在 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的呼叫棧:

你真的理解setState嗎?
其實還是和合成事件一樣,當 componentDidmount 執行的時候,react內部並沒有更新,執行完componentDidmount 後才去 commitUpdateQueue 更新。這就導致你在 componentDidmountsetState 完去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 這種繫結事件的形式都屬於原生事件。

你真的理解setState嗎?
原生事件的呼叫棧就比較簡單了,因為沒有走合成事件的那一大堆,直接觸發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 ,通過 firstUpdatelastUpdatelastUpdate.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,而在 setTimmoutsetState 是可以同步拿到更新結果,所以 setTimeout 中的兩次輸出2,3,最終結果就為 0, 0, 2, 3

總結 :

  1. setState 只在合成事件和鉤子函式中是“非同步”的,在原生事件和 setTimeout 中都是同步的。
  2. setState的“非同步”並不是說內部由非同步程式碼實現,其實本身執行的過程和程式碼都是同步的,只是合成事件和鉤子函式的呼叫順序在更新之前,導致在合成事件和鉤子函式中沒法立馬拿到更新後的值,形式了所謂的“非同步”,當然可以通過第二個引數 setState(partialState, callback) 中的callback拿到更新後的結果。
  3. setState 的批量更新優化也是建立在“非同步”(合成事件、鉤子函式)之上的,在原生事件和setTimeout 中不會批量更新,在“非同步”中如果對同一個值進行多次 setStatesetState 的批量更新策略會對其進行覆蓋,取最後一次的執行,如果是同時 setState 多個不同的值,在更新時會對其進行合併批量更新。

以上就是我看了部分程式碼後的粗淺理解,對原始碼細節的那塊分析的較少,主要是想讓大家理解setState在不同的場景,不同的寫法下到底發生了什麼樣的一個過程和結果,希望對大家有幫助,由於是個人的理解和見解,如果哪裡有說的不對的地方,歡迎大家一起指出並討論。

另外,幫朋友打個廣告 :

有好友整理了一波內推崗位,已釋出到github,感興趣的可以聯絡cXE3MjcwNDAxNDE=

相關文章