iT邦幫忙

0

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

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

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

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

請注意,在React 18以後,所有的setState都會是非同步的。

Part.3 - setState是同步還是非同步的?

在React 17以前的class component中(setState)

藉由上一篇,我們可以知道為了透過實作batching進行效能優化,透過React機制所呼叫的setState都是非同步的,也就是當呼叫setState的當下state並不會馬上被改變。

這裡的React機制指的是包含生命週期函數、SyntheticEvent handler等 (如: 以React.createElement或JSX呈現的html element上的onClick、onChange),詳細SyntheticEvent列表請參考官方文件

所以,在下方的程式碼中,我們會發現在handleClick後的console.log印出的都是state修改前的值。(下方有function component版本)

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

    handleClick = (e) => {
        // "e.target.value" is "this.state.price"
        this.setState({ price: Number(e.target.value) + 10 });
        console.log(`price is ${e.target.value}`);
    };

    render() {
        return (
            <div>
                <p> Apple is ${this.state.price}</p>
                <button
                    id="price-control"
                    value={this.state.price}
                    onClick={this.handleClick}
                >
                    Add Apple's price
                </button>
            </div>
        );
    }
}

但是當我們不是使用React機制呼叫setState時,由於batching機制不存在,setState就會是同步的。例如: 原生addEvent listener的callback function、setTimoout的callback function.....等。在下方的範例中,我們會發現setState後馬上印出的值會是新state值。

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

    handleClick = (e) => {
        // "e.target.value" is "this.state.price"
        this.setState({ price: Number(e.target.value) + 10 });
        console.log(`price is ${e.target.value}`);
    };

    componentDidMount() {
        document
            .getElementById('price-control')
            .addEventListener('click', this.handleClick);
    }

    componentWillUnmount() {
        document
            .getElementById('price-control')
            .removeEventListener('click', this.handleClick);
    }

    render() {
        return (
            <div>
                <p> Apple is ${this.state.price}</p>
                <button
                    id="price-control"
                    value={this.state.price}
                >
                    Add Apple's price
                </button>
            </div>
        );
    }
}

在在React 17以前的function component中(useState, useReducer)

在function component中的React hook也是一樣的,透過React機制所呼叫的setState都是非同步,也就是當呼叫setState的當下state並不會馬上被改變。可以試著執行、比較下列程式碼的執行結果

  • 非同步版本 - 透過SyntheticEvent handler觸發handleClick
import { useState,  useCallback } from 'react';

export default function Apple() {
    const [price, setPrice] = useState(0);

    // 透過JSX button的onClick觸發
    const handleClick = useCallback((e) => {
        setPrice(Number(e.target.value) + 10);
        console.log(e.target.value);
    }, []);

    return (
        <div>
            <p> Apple is ${price}</p>
            <button 
                id="price-control" 
                value={price} 
                onClick={handleClick}
            >
                Add Apple's price
            </button>
        </div>
    );
}
  • 同步版本 - 透過原生addEventListener callback function觸發handleClick,呼叫setState的當下state馬上會被改變
import { useState, useEffect, useCallback } from 'react';

export default function Apple() {
    const [price, setPrice] = useState(0);

    // 透過原生event listener觸發
    const handleClick = useCallback((e) => {
        // "e.target.value" is "price"
        setPrice(Number(e.target.value) + 10);
        console.log(e.target.value);
    }, []);

    useEffect(() => {
        document
            .getElementById('price-control')
            .addEventListener('click', handleClick);
        return () => {
            document
                .getElementById('price-control')
                .removeEventListener('click', handleClick);
        };
    }, [handleClick]);

    return (
        <div>
            <p> Apple is ${price}</p>
            <button id="price-control" value={price}>
                Add Apple's price
            </button>
        </div>
    );
}

React 18之後(2021/10/08補充更新)

在2021年中公布的React 18 alpha版中,釋出了新的ReactDOM api ReactDOM.createRoot。同時也公布了新的auto batching機制。在auto batching下,無論是透過SyntheticEvent、原生event還是setTimeout等,任何呼叫setState的方式都會實作batching機制。

「也就是說,React 18後,所有的setState都會是非同步的。」

懶人包: 所以,setState是同步還是非同步的?

  • React 18(含)以後: 所有的setState都會是非同步的

  • React 17(含)以前
    粗略來說,我們可以根據「是誰呼叫了setState」分成這兩種狀況:

    • 非同步(async): 在React機制中直接或間接呼叫。
      • 常見情境:
        • 生命週期函數
        • useEffect, useLayoutEffect
        • SyntheticEvent,如:以React.createElement或JSX呈現的html element上的onClick、onChange handler。可參考在上篇中的介紹。
    • 同步(sync): 不是在React機制中直接或間接呼叫。
      • 常見情境:
        • 原生Event listener的callback function
        • setTimoout的callback function

    註: setState的非同步執行機制不同於event loop,event loop是透過WEB API執行callback,而React是將更新state的行為在React更新流程中延遲執行,但依然是在主線程(Thread)內。

參考資料:
https://reactjs.org/docs/state-and-lifecycle.html#state-updates-may-be-asynchronous
https://zhuanlan.zhihu.com/p/54919571

Part.4 - 如何正確的取得setState後的新state值?

既然大多數的時候,setState都是非同步的,那麼該如何取得state被更新後的值呢? 以下我們會分別針對function component和class component討論。

在function component中 (React hook)

在function component中,如果我們想要拿到某個state被setState後的值,應該要為這個state多建立一個useEffect,並把該state被改變後要做的事情(副作用)放在這個新useEffect內。

下方是在建立元件後初始化state值,再檢視新的state值的作法:

import { useState, useEffect } from 'react';


export default function Apple() {
    const [price, setPrice] = useState(0);

    // ---正確的作法---
    useEffect(() => {
        setPrice(10);
    }, []);

    useEffect(() => {
        console.log(price);
    }, [price]);
    
    //----------------
    
    /* ---錯誤的方法一---
    useEffect(() => {
        setPrice(10);
        console.log(price);
    }, []);
    ----------------*/
    
    /* ---錯誤的方法二---
    useEffect(() => {
        setPrice(10);
        console.log(price);
    }, [price]);
    ----------------*/

    return (
        <div>
            <p> Apple is ${price}</p>
        </div>
    );
}

另外,useState給予的setState function接收的參數原本其實也是函式,有的時候我們會想在設定某個state後,馬上根據同個state更新後的值去做下一次同個state的更新,此時我們可以改用「函式回傳值」的方式傳入新的值。React會把更新後的state值傳入此function參數中,所以我們能在函式中用更新後的state值去做下一次同個state的更新。這樣的做法也能避免使用useEffect時需要思考是否會出現無限遞迴的情形。

在下方的範例中,即使都是在建立元件後連續加10加3次,以非函式參數作法,price會變成10,且為了只在建立元件後執行,沒有把price放在useEffect的dependence參數中,嚴格模式下React會報錯:

import { useState } from 'react';

export default function Apple() {
    const [price, setPrice] = useState(0);

    useEffect(() => {
        setPrice(price + 10);
        setPrice(price + 10);
        setPrice(price + 10);
    }, []);

    return (
        <div>
            <p> Apple is ${price}</p>
        </div>
    );
}

而改傳入函式時,price會在建立元件後變成30。也因為運算的是React傳入函式的參數,而不是引入state本身,沒有違反嚴格模式的問題:

import { useState } from 'react';

export default function Apple() {
    const [price, setPrice] = useState(0);

    useEffect(() => {
        setPrice(prePrice => prePrice + 10);
        setPrice(prePrice => prePrice + 10);
        setPrice(prePrice => prePrice + 10);
    }, []);

    return (
        <div>
            <p> Apple is ${price}</p>
        </div>
    );
}

同時,使用useReducer,藉由reducer function封裝處理state的邏輯也是可行的方法,也沒有違反嚴格模式的問題:

import { useReducer } from 'react';

function priceRedcuer(prevState, action) {
    switch (action.type) {
        case 'ADD':
            return prevState + 10;
        default:
            return prevState;
    }
}

export default function Apple() {
    const [price, priceDispatch] = useReducer(priceRedcuer, 0);

    useEffect(() => {
        priceDispatch({ type: 'ADD' });
        priceDispatch({ type: 'ADD' });
        priceDispatch({ type: 'ADD' });
    }, []);

    return (
        <div>
            <p> Apple is ${price}</p>
        </div>
    );
}

在class component中(setState)

在class component中取得修改state後的值有兩種作法。第一種是利用setState函式本身提供的第二個參數,這個參數接收一個function,React會在state被更新後呼叫這個callback function。我們就能在這個function參數中定義獲得新state後要做的事情。

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

    componentDidMount() {
        this.setState({ price: 10 }, () => {
            console.log(this.state.price);
        });
    }

    render() {
        return (
            <div>
                <p> Apple is ${this.state.price}</p>
            </div>
        );
    }
}

第二種方法則是利用生命週期函數中的componentDidUpdate。但需要特別注意的是,當該元件中任何state被setState設定時,componentDidUpdate都會被重新呼叫。所以必須特別注意目前的邏輯是否有出現無限遞迴的可能。

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

    componentDidMount() {
        this.setState({ price: 10 });
    }

    componentDidUpdate(prevProps, prevState, snapshot) {
        // 這個if是為了避免之後新增其他邏輯時出現非預期錯誤
        if (prevState.price !== this.state.price) {
            console.log(this.state.price);
        }
    }

    render() {
        return (
            <div>
                <p> Apple is ${this.state.price}</p>
            </div>
        );
    }
}

另外,setState接收的第一個參數原本其實也是函式。如果想在某次設定state後,根據前次state更新後的值去做下一次的state更新,React會把更新後的state、props值傳入此function參數中,所以我們能在此function用更新後的state值去做下一次的state更新。

在下方的範例中,即使都是連續加10加3次,錯誤的作法下,price會在建立元件後變成10;正確的作法下,price會在建立元件後變成30。

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

    componentDidMount() {
        // 錯誤的作法
        /*  
        this.setState({ price: this.state.price + 10 });
        this.setState({ price: this.state.price + 10 });
        this.setState({ price: this.state.price + 10 });
        */
        
        // 正確的作法
        for (let i = 0; i < 3; ++i) {
            this.setState((state, props) => {
                return { price: state.price + 10 };
            });
        }
    }

    render() {
        return (
            <div>
                <p> Apple is ${this.state.price}</p>
            </div>
        );
    }
}

參考資料: https://zh-hant.reactjs.org/docs/react-component.html#setstate

心得與總結

關於React中setState的同步/非同步一直以來都是一個很容易遇到、也很容易犯錯的問題。無論對剛入門或是對有一定的程度的開發者來說都是很值得研究。剛好趁自己有最近有時間去了解他的機制和原因,利用這兩篇紀錄一下,如果有想法或是有錯誤都歡迎留言與我討論:)

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


2 則留言

0
Dylan
iT邦新手 5 級 ‧ 2021-08-07 17:35:04

感謝大大分享的這兩篇文。
我在看到 setPrice 傳入函式的範例時,玩了一下,我改成以下這樣的 code:

useEffect(() => {
  console.log("== start ==");
  setPrice(prePrice => {
    console.log(1);

    return prePrice + 10;
  });
  setPrice(prePrice => {
    console.log(2);

    return prePrice + 10;
  });
  setPrice(prePrice => {
    console.log(3);

    return prePrice + 10;
  });
  console.log("== end ==");
}, []);

log 順序是這樣,不太明白為何只有 1 是在 start 與 end 之間,想請大大賜教:

"== start =="
1
"== end =="
2
3

Hi, 我去翻了一下React原始碼:

如果你一路去查useState的定義,會發現useState實際上回傳給你的是一個叫做mountState的函式。在mountState中,我們可以知道setState函式是從一個叫做dispatchAction的函式轉化而來的。在dispatchAction的定義裡,我們可以看到當中包含了在我上一篇最後提及的fiber演算機制。所以,我猜測是React在執行你的程式碼時,第一個setState處在的fiber和其他setState不同,才會出現整體執行順序看起來怪怪的樣子。

以上我是用我能理解的來回覆你,但因為暫時沒時間好好研究fiber,我沒辦法保證這個答案是完全正解,如果想確定答案的話可以到React的repo去發個issue問看看。

Dylan iT邦新手 5 級 ‧ 2021-08-08 00:14:46 檢舉

瞭解了,還是感謝大大細心回應?

Dylan iT邦新手 5 級 ‧ 2021-08-08 00:15:31 檢舉

emoji 沒出現⋯

0
斯人
iT邦研究生 2 級 ‧ 2021-11-12 19:22:57

請問大大有沒有寫導讀React原始碼的文章可以參考 XD

我也想找時間找相關的文章研究看看,但是因為一直沒空,目前仍是停滯狀態哈哈

繁中這方面的資料真的比較少,之前有看到這位邦友有寫幾篇,不過我也還沒時間細看XD。其他可能就要找簡中或英文原文的reference了。

我要留言

立即登入留言