1. 程式人生 > >你真的理解setState嗎?

你真的理解setState嗎?

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

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

setState真的是非同步的嗎 ?

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

看之前,為了方便理解和簡化流程,我們預設react內部程式碼執行到performWorkperformWorkOnRoot

performSyncWorkperformAsyncWork這四個方法的時候,就是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的呼叫棧



從 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的呼叫棧:

鉤子函式中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;
  }
}

最後看個