深入剖析setState同步非同步機制

陌上兮月發表於2021-01-17

關於 setState

setState 的更新是同步還是非同步,一直是人們津津樂道的話題。不過,實際上如果我們需要用到更新後的狀態值,並不需要強依賴其同步/非同步更新機制。在類元件中,我們可以通過this.setState的第二引數、componentDidMountcomponentDidUpdate等手段來取得更新後的值;而在函式式元件中,則可以通過useEffect來獲取更新後的狀態。所以這個問題,其實有點無聊。

不過,既然大家都這麼樂於討論,今天我們就係統地梳理一下這個問題,主要分為兩方面來說:

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

類元件中的 this.setState

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

  • legacy模式中,更新可能為同步,也可能為非同步;
  • concurrent模式中,一定是非同步。

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

  • 通過ReactDOM.render(<App />, rootNode)方式建立應用,則為 legacy 模式,這也是create-react-app目前採用的預設模式;

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

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

是的,這不是玄學,我們來先丟擲結論,再來逐步解釋它。

  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 的主流程:

  1. 呼叫this.setState(newState)
  2. newState會存入 pending 佇列;
    3,判斷是不是batchUpdate
    4,如果是batchUpdate,則將元件先儲存在所謂的髒元件dirtyComponents中;如果不是batchUpdate,那麼就遍歷所有的髒元件,並更新它們。

由此我們可以判定:所謂的非同步更新,都命中了batchUpdate,先儲存在髒元件中就完事;而同步更新,總是會去更新所有的髒元件。

非常有意思,看來是否命中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 是不是同步的,這裡都會列印出舊值。

相關文章