iT邦幫忙

3

理解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


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0
Dylan
iT邦新手 3 級 ‧ 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
Andy Chang iT邦研究生 4 級 ‧ 2021-08-07 20:26:14 檢舉

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

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

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

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

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

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

emoji 沒出現⋯

0
wrxue
iT邦好手 1 級 ‧ 2021-11-12 19:22:57

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

Andy Chang iT邦研究生 4 級 ‧ 2021-11-13 22:43:44 檢舉

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

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

0
AndrewYEE
iT邦新手 3 級 ‧ 2023-01-30 19:33:37

您好,在正確取得更新後的state的段落

且為了只在建立元件後執行,沒有把price放在useEffect的dependence參數中,嚴格模式下React會報錯

請問這是為什麼呢?

看更多先前的回應...收起先前的回應...
Andy Chang iT邦研究生 4 級 ‧ 2023-01-30 20:15:14 檢舉

官方文件中介紹useEffect的敘述是這樣的

The Effect Hook lets you perform side effects in function components:

意思是雖然從class component的角度來看,useEffect是生命週期;但是從function component的角度來看,useEffect是用來表示「哪些state/props/相依變數變化時應該要觸發的副作用」。也就是說,dependence參數是用來指出「這個副作用跟哪些state/props/相依變數有關」
https://reactjs.org/docs/hooks-effect.html

在官方教學文件中有這麼一段話

Props and state aren’t the only reactive values. Values that you calculate from them are also reactive. If the props or state change, your component will re-render, and the values calculated from them will also change. This is why all variables from the component body used by the Effect should also be in the Effect dependency list.

簡而言之,因為state/props變數應該要準確反映出他們最新的值(這裡其實講Reactive會比較好,但我不確定怎麼翻譯),所以React希望當某個state/props產生變化時,所有會跟該state/props有相依性的地方(ex: 副作用)也應該要拿到其最新的值並根據定義好的邏輯做出反應,藉此降低在專案中未處理到的case
https://beta.reactjs.org/learn/lifecycle-of-reactive-effects#all-variables-declared-in-the-component-body-are-reactive

AndrewYEE iT邦新手 3 級 ‧ 2023-01-30 21:14:20 檢舉

謝謝您
我大概了解,意思是不是說官方希望我們如果有操作到state或props,則被操作的state或props都應該要有對應的useEffect(綁定dependence)這樣嗎?

另外我在其他篇文章也多次看到 "side effect" 這個詞,請問這個詞具體意思是甚麼呢? 我目前理解是類似做ajax這類的事情

Andy Chang iT邦研究生 4 級 ‧ 2023-01-30 22:57:22 檢舉

意思是不是說官方希望我們如果有操作到state或props,則被操作的state或props都應該要有對應的useEffect(綁定dependence)這樣嗎?

對,更精確的說法是 stateprops 和「由state/props運算而來的變數

"side effect" 這個詞,請問這個詞具體意思是甚麼呢?

意思是對其他對象/作用域造成影響。不過這有點文言文,用舉例理解比較容易。下面的範例中,我們可以說add函式不對任何對象有副作用。像這樣的函式又被稱為「pure function」。

var ctn = 0;

/* addCtn函式 和 ctn 之間有副作用,執行結果因ctn的值而改變 */
function addCtn(num) {
    return ctn + num;
}
console.log(addCtn(1)); // output: 1
ctn += 2;
console.log(addCtn(1)); // output: 3

/* add函式 不和任何其他對象有副作用,執行結果不會因為任何外在環境改變 */
function add(target, num) {
    return target + num;
}
console.log(add(0, 1)); // output: 1
console.log(add(0, 1)); // output: 1
AndrewYEE iT邦新手 3 級 ‧ 2023-01-31 09:07:51 檢舉

了解 非常感謝您的回答!

我要留言

立即登入留言