博客 / 詳情

返回

React 中 setState 是一個宏任務還是微任務?

最近有個朋友面試,面試官問了個奇葩的問題,也就是我寫在標題上的這個問題。

能問出這個問題,面試官應該對 React 不是很瞭解,也是可能是看到面試者簡歷裏面有寫過自己熟悉 React,面試官想通過這個問題來判斷面試者是不是真的熟悉 React 🤣。

面試官的問法是否正確?

面試官的問題是,setState 是一個宏任務還是微任務,那麼在他的認知裏,setState 肯定是一個異步操作。為了判斷 setState 到底是不是異步操作,可以先做一個實驗,通過 CRA 新建一個 React 項目,在項目中,編輯如下代碼:

import React from 'react';
import logo from './logo.svg';
import './App.css';

class App extends React.Component {
  state = {
    count: 1000
  }
  render() {
    return (
      <div className="App">
        <img
          src={logo} alt="logo"
          className="App-logo"
          onClick={this.handleClick}
        />
        <p>我的關注人數:{this.state.count}</p>
      </div>
    );
  }
}

export default App;

頁面大概長這樣:

上面的 React Logo 綁定了一個點擊事件,現在需要實現這個點擊事件,在點擊 Logo 之後,進行一次 setState 操作,在 set 操作完成時打印一個 log,並且在 set 操作之前,分別添加一個宏任務和微任務。代碼如下:

handleClick = () => {
  const fans = Math.floor(Math.random() * 10)
  setTimeout(() => {
    console.log('宏任務觸發')
  })
  Promise.resolve().then(() => {
    console.log('微任務觸發')
  })
  this.setState({
    count: this.state.count + fans
  }, () => {
    console.log('新增粉絲數:', fans)
  })
}

很明顯,在點擊 Logo 之後,先完成了 setState 操作,然後再是微任務的觸發和宏任務的觸發。所以,setState 的執行時機是早於微任務與宏任務的,即使這樣也只能説它的執行時機早於 Promise.then,還不能證明它就是同步任務。

handleClick = () => {
  const fans = Math.floor(Math.random() * 10)
  console.log('開始運行')
  this.setState({
    count: this.state.count + fans
  }, () => {
    console.log('新增粉絲數:', fans)
  })
  console.log('結束運行')
}

這麼看,似乎 setState 又是一個異步的操作。主要原因是,在 React 的生命週期以及綁定的事件流中,所有的 setState 操作會先緩存到一個隊列中,在整個事件結束後或者 mount 流程結束後,才會取出之前緩存的 setState 隊列進行一次計算,觸發 state 更新。只要我們跳出 React 的事件流或者生命週期,就能打破 React 對 setState 的掌控。最簡單的方法,就是把 setState 放到 setTimeout 的匿名函數中。

handleClick = () => {
  setTimeout(() => {
    const fans = Math.floor(Math.random() * 10)
    console.log('開始運行')
    this.setState({
      count: this.state.count + fans
    }, () => {
      console.log('新增粉絲數:', fans)
    })
    console.log('結束運行')
  })
}

由此可見,setState 本質上還是在一個事件循環中,並沒有切換到另外宏任務或者微任務中,在運行上是基於同步代碼實現,只是行為上看起來像異步。所以,根本不存在面試官的問題。

React 是如何控制 setState 的 ?

前面的案例中,setState 只有在 setTimeout 中才會變得像一個同步方法,這是怎麼做到的?

handleClick = () => {
  // 正常的操作
  this.setState({
    count: this.state.count + 1
  })
}
handleClick = () => {
  // 脱離 React 控制的操作
  setTimeout(() => {
    this.setState({
      count: this.state.count + fans
    })
  })
}

先回顧之前的代碼,在這兩個操作中,我們分別在 Performance 中記錄一次調用棧,看看兩者的調用棧有何區別。

正常操作

脱離 React 控制的操作

在調用棧中,可以看到 Component.setState 方法最終會調用enqueueSetState 方法 ,而 enqueueSetState 方法內部會調用 scheduleUpdateOnFiber 方法,區別就在於正常調用的時候,scheduleUpdateOnFiber 方法內只會調用 ensureRootIsScheduled ,在事件方法結束後,才會調用 flushSyncCallbackQueue 方法​。而脱離 React 事件流的時候,scheduleUpdateOnFiberensureRootIsScheduled 調用結束後,會直接調用 flushSyncCallbackQueue 方法,這個方法就是用來更新 state 並重新進行 render。

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  if (lane === SyncLane) {
    // 同步操作
    ensureRootIsScheduled(root, eventTime);
    // 判斷當前是否還在 React 事件流中
    // 如果不在,直接調用 flushSyncCallbackQueue 更新
    if (executionContext === NoContext) {
      flushSyncCallbackQueue();
    }
  } else {
    // 異步操作
  }
}

上述代碼可以簡單描述這個過程,主要是判斷了 executionContext 是否等於 NoContext 來確定當前更新流程是否在 React 事件流中。

眾所周知,React 在綁定事件時,會對事件進行合成,統一綁定到 document 上( react@17 有所改變,變成了綁定事件到 render 時指定的那個 DOM 元素),最後由 React 來派發。

所有的事件在觸發的時候,都會先調用 batchedEventUpdates$1 這個方法,在這裏就會修改 executionContext 的值,React 就知道此時的 setState 在自己的掌控中。

// executionContext 的默認狀態
var executionContext = NoContext;
function batchedEventUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= EventContext; // 修改狀態
  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;
        // 調用結束後,調用 flushSyncCallbackQueue
    if (executionContext === NoContext) {
      flushSyncCallbackQueue();
    }
  }
}

所以,不管是直接調用 flushSyncCallbackQueue ,還是推遲調用,這裏本質上都是同步的,只是有個先後順序的問題。

未來會有異步的 setState

如果你有認真看上面的代碼,你會發現在 scheduleUpdateOnFiber 方法內,會判斷 lane 是否為同步,那麼是不是存在異步的情況?

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  if (lane === SyncLane) {
    // 同步操作
    ensureRootIsScheduled(root, eventTime);
    // 判斷當前是否還在 React 事件流中
    // 如果不在,直接調用 flushSyncCallbackQueue 更新
    if (executionContext === NoContext) {
      flushSyncCallbackQueue();
    }
  } else {
    // 異步操作
  }
}

React 在兩年前,升級 fiber 架構的時候,就是為其異步化做準備的。在 React 18 將會正式發佈 Concurrent 模式,關於 Concurrent 模式,官方的介紹如下。

什麼是 Concurrent 模式?

Concurrent 模式是一組 React 的新功能,可幫助應用保持響應,並根據用户的設備性能和網速進行適當的調整。在 Concurrent 模式中,渲染不是阻塞的。它是可中斷的。這改善了用户體驗。它同時解鎖了以前不可能的新功能。

現在如果想使用 Concurrent 模式,需要使用 React 的實驗版本。如果你對這部分內容感興趣可以閲讀我之前的文章:《React 架構的演變 - 從同步到異步》。

user avatar zzd41 頭像 guizimo 頭像 tigerandflower 頭像 yaofly 頭像 esunr 頭像 chongdianqishi 頭像 mulander 頭像 waweb 頭像
8 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.