iT邦幫忙

3

理解React的setState到底是同步還是非同步(上)

  • 分享至 

  • xImage
  •  

在上個月初的時候,偶然在IThelp看到這篇討論 setState後畫面沒有立即Render,決定趁自己有空的時候把相關的概念搞清楚。

以下內容是自己參考多份官方文件後的整理,如果有想法或是有錯誤都歡迎留言與我討論。

本系列文章一共分上下兩篇。上篇會先從React的機制來探討如果setState是同步/非同步會發生什麼事,下篇會統整setState在什麼時候是同步/非同步,以及該如何正確的取得setState後的新state值。如果是剛入門,想先跳過底層原理解釋的朋友可以直接看下篇

Part.1 - 認識React batching

首先,我們先假設setState絕對會是同步的(呼叫setState後state會馬上被改變)。

在React中,我們很常都會透過setState去更新state和props,藉此觸發React的更新機制(reconciliation),比較Virtual DOM後,再去渲染畫面。然而React開發者在處理setState和判斷元件更新的關係時,一些效能問題出現了。我們來看看這個例子:

下方程式碼中,Parent引入了Child。當Child被點擊時,由於event bubbling,其父層Parent中的div也會觸發onClick事件。

import { Component } from 'react';

class Child extends Component {
    constructor(props) {
        super(props);
        this.state = { count: 0 };
    }
    render() {
        return (
            <button
                onClick={() => this.setState({ count: this.state.count + 1 })}
            >
                Child clicked {this.state.count} times
            </button>
        );
    }
}

export default class Parent extends Component {
    constructor(props) {
        super(props);
        this.state = { count: 0 };
    }
    render() {
        return (
            <div onClick={() => this.setState({ count: this.state.count + 1 })}>
                Parent clicked {this.state.count} times
                <Child />
            </div>
        );
    }
}

直覺上來說,假設setState當下state就會被改變,我們預期的更新流程應該如下:

  1. 點擊行為一路向下補獲至target(Child的button)
  2. 呼叫綁定在Child的button的onClick function,觸發setState
  3. setState導致Child的state被改變
  4. Child元件re-render
  5. onClick事件透過event bubble冒泡回Parent的div
  6. 呼叫綁定在Parent的div的onClick function,觸發setState
  7. setState導致Parent的state被改變
  8. Parent元件re-render
  9. 由於Child是Parent的子元素,當Parent元件re-render時,Child元件也要re-render

觀察上述流程,我們會發現階段4產生的re-render是不必要的,因為在最後階段時Child又再re-render了一次。

由於這樣的狀況會導致資源浪費,所以在React 15(含)以前,React團隊決定,當setState在React機制中被呼叫(例如: 生命週期、合成事件),開始進行reconciliation時,實際上React會先等「該次event會觸發的所有event handler」都執行完後,再去更新state,並一次判斷哪些元件要被重新渲染。這個機制稱為「batching」。

也就是上述範例在React中實際的更新流程如下

  1. 點擊行為一路向下補獲至target(Child的button)
  2. 呼叫綁定在Child的button的onClick function,觸發setState。(state未被改變,而是將要執行的改變push進一queue裡)
  3. onClick事件透過event bubble冒泡回Parent的div
  4. 呼叫綁定在Parent的div的onClick function,觸發setState。(state未被改變,而是將要執行的改變push進一queue裡)
  5. React從setState queue裡統一處理state的更新,判斷那些元件要re-render
  6. Parent元件re-render
  7. Child元件re-render

註: 什麼是 SyntheticEvent(合成事件)?

在React中,我們幾乎都會透過以JSX或是React.createElement呈現的html element上的onClick、onChange這些屬性進行事件處理、讓使用者的行為觸發呼叫setState函式、更新元件。而這些屬性和原生利用addEventListeneronclick做事件處理不同的地方在,React針對自己的需求,在事件發生、呼叫handler到更新元件的過程中,多進行了某些加工。一個很明顯的例子是在使用ReactDOM.createPortal時,雖然實際渲染在DOM上的元素和原本JSX中的巢狀結構不再有父子關係,但React仍然會將event bubbling實作回JSX中引入其引入的父層元素中。

這種在React中的事件處理稱為SyntheticEvent(合成事件)。

import { createPortal } from 'react-dom';

function Child() {
    return createPortal(
        <button>ChildReact</button>,
        document.getElementById('portal-react')
    );
}

export default function Parent() {
    return (
        <div onClick={() => console.log('Parent被點擊了')}>
            <Child />
        </div>
    );
}

參考資料: https://zh-hant.reactjs.org/docs/events.html

batching機制也同時避免了在同一次事件中大量呼叫setState所造成的資源浪費。例如,在下方的程式碼中,我們定義當Child中button被點擊時,在Parent中觸發三次setState。觀察實際執行結果,會發現React並不會在呼叫一次setState後就馬上去根據state新的值去更新DOM,而是根據所有setState依序執行後的state值去更新一次元件,所以「我被更新了」只會印出一次。

import { Component } from 'react';

class Child extends Component {
    constructor(props) {
        super(props);
    }
    render() {
        return (
            <div>
                <p>parent count is {this.props.count}</p>
                <button onClick={this.props.handleClick}>add Count</button>
            </div>
        );
    }
}

export default class Parent extends Component {
    constructor(props) {
        super(props);
        this.state = { count: 0 };
    }

    addCount = () => {
        let currentCount = this.state.count;
        for (let i = 0; i < 3; ++i) {
            currentCount++;
            this.setState({ count: currentCount });
        }
    };

    render() {
        console.log("我被更新了");
        return (
            <div>
                <Child handleClick={this.addCount} />
            </div>
        );
    }
}

參考資料: https://overreacted.io/react-as-a-ui-runtime/#batching

Part.2 - 確保Internal Consistency

雖然batching可能是個讓setState需要具有非同步特性的原因,然而其實只要不讓元件re-render,立即更新state也可以做到batching。我們試著來把剛剛的流程改這樣:

  1. 點擊行為一路向下補獲至target(Child的button)
  2. 呼叫綁定在Child的button的onClick function,觸發setState
  3. setState導致Child的state被改變,並在某個地方記住此元件要更新
  4. onClick事件透過event bubble冒泡回Parent的div
  5. 呼叫綁定在Parent的div的onClick function,觸發setState
  6. setState導致Parent的state被改變,並在某個地方記住此元件要更新
  7. React統一處理元件的更新
  8. Parent元件re-render
  9. Child元件re-render

看起來很完美,對吧?

然而這個時候,另一個問題又出現了: props

由於React只有在父元件被re-render後,子元件才能知道其父元件賦予自己的props值,所以如果props也要隨著state的改變而同時被改變,那state改變後該元件就應該要馬上被re-render,卻也導致沒辦法進行batching了。這會帶來相當大的資源浪費,所以,無論如何,props的改變仍然是非同步的。

那麼這會帶來什麼問題呢? 來看看以下這個範例,Child負責顯示Parent的count數量,也能在Child連續兩次增加Parent中的count值:

import { Component } from 'react';

class Child extends Component {
    constructor(props) {
        super(props);
    }

    handleClick = () => {
        this.props.handleClick();
        console.log(this.props.count);
        this.props.handleClick();
        console.log(this.props.count);
    };

    render() {
        return (
            <div>
                <p>parent count is {this.props.count}</p>
                <button onClick={this.handleClick}>add Count 2 times</button>
            </div>
        );
    }
}

export default class Parent extends Component {
    constructor(props) {
        super(props);
        this.state = { count: 0 };
    }

    addCount = () => {
        this.setState({ count: this.state.count +1 });
        console.log(this.state.count);
    };

    render() {
        console.log('更新了');
        return (
            <div>
                <Child props={this.state.count} handleClick={this.addCount} />
            </div>
        );
    }
}

依照我們剛剛制定state更新是同步、props更新是非同步的規則,當點擊Child中的button時,會產生的流程如下:

  1. child呼叫this.handleClick
  2. child的this.handleClick第一次呼叫Parent綁定在this.props.handleClick上的addCount
  3. Parent中的addCount呼叫setState
  4. Parent印出1(state.count的值)
  5. Child印出0(props.count的值)
  6. child的this.handleClick第二次呼叫Parent綁定在this.props.handleClick上的addCount
  7. Parent中的addCount呼叫setState
  8. Parent印出2(state.count的值)
  9. Child印出0(props.count的值)
  10. React統一處理元件的更新
  11. 渲染Parent
  12. 渲染Child

注意到了嗎? 明明Child的props.count綁定了Parent的state.count,但是在這個範例中,同一時間點得到的值會是不同的。由於這樣的不一致性容易造成開發者的困擾,所以,React團隊決定batching機制下,藉由讓setState具有非同步特性,得以使被綁定的state和props之間能保持一致。

小結

到這邊,我們可以知道React setState之所以要是非同步的原因之一是batching和其延伸問題。另外還有一個原因是如果setState是非同步,React團隊會更方便實現在React 16後推出的React Fiber(非同步渲染機制)。和本文中提及一次處理所有元件的更新不同,雖然batching機制依然存在,但React Fiber將更新流程拆個多個片段,這樣「將原本一大包的更新拆成片段」的做法能夠讓瀏覽器在片段之間處理其他工作,解決了過去React在更新時偶而會發生的掉偵、卡頓問題。

如果setState是非同步的,在呼叫setState後,React就能先透過一套演算法重新算出該更新任務的優先權,根據優先權再去決定該更新任務適合在哪個片段中執行。這部份細節自己暫時沒時間研究,就不多做介紹了。

下一篇中,我們會整理前面提及setState是非同步的時機點,以及如何正確的取得setState後的新state值。

最後偷偷廣告一下,自己在11屆和12屆鐵人賽的React.js系列文修訂後和深智數位合作,最近在天瓏開始預購了,想學React的朋友可以參考看看:
https://www.tenlong.com.tw/products/9789860776188?list_name=srh

參考資料: https://github.com/facebook/react/issues/11527#issuecomment-360199710

下篇傳送門:理解React的setState到底是同步還是非同步(下)


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

1
Dylan
iT邦新手 1 級 ‧ 2021-08-06 23:50:36

最後 props 範例的 code,好像有寫錯:
this.setState({ count: count +1 }); => this.setState({ count: this.state.count +1 });

<Child props={this.count} handleClick={this.addCount} /> => <Child count={this.state.count} handleClick={this.addCount} />

Andy Chang iT邦研究生 3 級 ‧ 2021-08-07 00:06:53 檢舉

感謝你,在修改的時候沒注意到QQ 已更正

我要留言

立即登入留言