深刻剖析setState同步異步機制

關於 setState

setState 的更新是同步仍是異步,一直是人們津津樂道的話題。不過,實際上若是咱們須要用到更新後的狀態值,並不須要強依賴其同步/異步更新機制。在類組件中,咱們能夠經過this.setState的第二參數、componentDidMountcomponentDidUpdate等手段來取得更新後的值;而在函數式組件中,則能夠經過useEffect來獲取更新後的狀態。因此這個問題,其實有點無聊。react

不過,既然你們都這麼樂於討論,今天咱們就係統地梳理一下這個問題,主要分爲兩方面來講:shell

  • 類組件(class-component)的更新機制
  • 函數式組件(function-component)的更新機制

類組件中的 this.setState

在類組件中,這個問題的答案是多樣的,首先拋第一個結論:npm

  • legacy模式中,更新可能爲同步,也可能爲異步;
  • concurrent模式中,必定是異步。

問題1、legacy 模式和 concurrent 模式是什麼鬼?

  • 經過ReactDOM.render(<App />, rootNode)方式建立應用,則爲 legacy 模式,這也是create-react-app目前採用的默認模式;bash

  • 經過ReactDOM.unstable_createRoot(rootNode).render(<App />)方式建立的應用,則爲concurrent模式,這個模式目前只是一個實驗階段的產物,還不成熟。app

legacy 模式下可能同步,也可能異步?

是的,這不是玄學,咱們來先拋出結論,再來逐步解釋它。dom

  1. 當直接調用時this.setState時,爲異步更新;
  2. 當在異步函數的回調中調用this.setState,則爲同步更新;
  3. 當放在自定義 DOM 事件的處理函數中時,也是同步更新。

實驗代碼以下:異步

class StateDemo extends React.Component {
    constructor(props) {
        super(props)
        this.state = {
            count: 0
        }
    }
    render() {
        return <div>
            <p>{this.state.count}</p>
            <button onClick={this.increase}>累加</button>
        </div>
    }
    increase = () => {
        this.setState({
            count: this.state.count + 1
        })
        // 異步的,拿不到最新值
        console.log('count', this.state.count)

        // setTimeout 中 setState 是同步的
        setTimeout(() => {
            this.setState({
                count: this.state.count + 1
            })
            // 同步的,能夠拿到
            console.log('count in setTimeout', this.state.count)
        }, 0)
    }

    bodyClickHandler = () => {
        this.setState({
            count: this.state.count + 1
        })
        // 能夠取到最新值
        console.log('count in body event', this.state.count)
    }

    componentDidMount() {
        // 本身定義的 DOM 事件,setState 是同步的
        document.body.addEventListener('click', this.bodyClickHandler)
    }
    componentWillUnmount() {
        // 及時銷燬自定義 DOM 事件
        document.body.removeEventListener('click', this.bodyClickHandler)
    }
}

要解答上述現象,就必須瞭解 setState 的主流程,以及 react 中的 batchUpdate 機制。函數

首先咱們來看看 setState 的主流程:this

  1. 調用this.setState(newState)
  2. newState會存入 pending 隊列;
    3,判斷是否是batchUpdate
    4,若是是batchUpdate,則將組件先保存在所謂的髒組件dirtyComponents中;若是不是batchUpdate,那麼就遍歷全部的髒組件,並更新它們。

由此咱們能夠斷定:所謂的異步更新,都命中了batchUpdate,先保存在髒組件中就完事;而同步更新,老是會去更新全部的髒組件。code

很是有意思,看來是否命中batchUpdate是關鍵。問題也隨之而來了,爲啥直接調用就能命中batchUpdate,而放在異步回調裏或者自定義 DOM 事件中就命中不了呢?

這就涉及到一個頗有意思的知識點:react 中函數的調用模式。對於剛剛的 increase 函數,還有一些咱們看不到的東西,如今咱們經過魔法讓其顯現出來:

increase = () => {
        // 開始:默認處於bashUpdate
        // isBatchingUpdates = true
        this.setState({
            count: this.state.count + 1
        })
        console.log('count', this.state.count)
        // 結束
        // isBatchingUpdates = false

    }
increase = () => {
        // 開始:默認處於bashUpdate
        // isBatchingUpdates = true
        setTimeout(() => {
            // 此時isBatchingUpdates已經設置爲了false
            this.setState({
                count: this.state.count + 1
            })
            console.log('count in setTimeout', this.state.count)
        }, 0)
        // 結束
        // isBatchingUpdates = false
    }

當 react 執行咱們所書寫的函數時,會默認在首位設置isBatchingUpdates變量。看到其中的差別了嗎?當 setTimeout 執行其回調時,isBatchingUpdates早已經在同步代碼的末尾被置爲false了,因此沒命中batchUpdate

那自定義 DOM 事件又是怎麼回事?代碼依然以下:

componentDidMount() {
    // 開始:默認處於bashUpdate
    // isBatchingUpdates = true
    document.body.addEventListener("click", () => {
      // 在回調函數裏面,當點擊事件觸發的時候,isBatchingUpdates早就已經設爲false了
      this.setState({
        count: this.state.count + 1,
      });
      console.log("count in body event", this.state.count); // 能夠取到最新值。
    });
    // 結束
    // isBatchingUpdates = false
  }

咱們能夠看到,當componentDidMount跑完時,isBatchingUpdates已經設置爲false了,而點擊事件後來觸發,並調用回調函數時,取得的isBatchingUpdates固然也是false,不會命中batchUpdate機制。

總結:

  • this.setState是同步仍是異步,關鍵就是看可否命中batchUpdate機制
  • 能不能命中,就是看isBatchingUpdatestrue仍是false
  • 能命中batchUpdate的場景包括:生命週期和其調用函數、React中註冊的事件和其調用函數。總之,是React能夠「管理」的入口,關鍵是「入口」。

這裏要注意一點:React去加isBatchingUpdate的行爲不是針對「函數」,而是針對「入口」。好比setTimeout、setInterval、自定義DOM事件的回調等,這些都是React「管不到」的入口,因此不會去其首尾設置isBatchingUpdates變量。

concurrent 模式必定是異步更新

由於這個東西只在實驗階段,因此要開啓 concurrent 模式,一樣須要將 react 升級爲實驗版本,安裝以下依賴:

npm install react@experimental react-dom@experimental

其餘代碼不用變,只更改 index 文件以下:

- ReactDOM.render(<App />, document.getElementById('root'));

+ ReactDOM.unstable_createRoot(document.getElementById('root')).render(<App />);

則能夠發現:其更新都是異步的,在任何狀況下都是如此。

關於函數式組件中 useState 的 setter

在函數式組件中,咱們會這樣定義狀態:

const [count, setCount] = useState(0)

這時候,咱們發現當咱們不管在同步函數仍是在異步回調中調用 setCount 時,打印出來的 count 都是舊值,這時候咱們會說:setCount 是異步的。

const [count, setCount] = useState(0);

  // 直接調用
  const handleStrightUpdate = () => {
    setCount(1);
    console.log(count); // 0
  };

  // 放在setTimeout回調中
  const handleSetTimeoutUpdate = () => {
    setTimeout(() => {
      setCount(1);
      console.log(count); // 0
    });
  };

setCount 是異步的,這確實沒錯,可是產生上述現象的緣由不僅是異步更新這麼簡單。緣由主要有如下兩點:

1,調用 setCount 時,會作合併處理,異步更新該函數式組件對應的 hooks 鏈表裏面的值,而後觸發重渲染(re-renders),從這個角度上來講,setCount確實是一個異步操做;

2,函數式的capture-value特性決定了console.log(count)語句打印的始終是一個只存在於當前幀的常量,因此就算不管 setCount 是否是同步的,這裏都會打印出舊值。

相關文章
相關標籤/搜索