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


<<:  理解React的setState到底是同步还是非同步(上)

>>:  Day 41 (PHP)

抓取资料库数据 - SQL基础语法(中)

上次我们已经学会要怎麽从资料库依照各个表取出我们想要的栏位,也可以透过条件筛选的方式过滤我们想要的资...

[Day 30] - 终成行男

呼,想当初在铁人赛开赛前还在犹豫到底要不要开赛呢? 参赛後是要写什麽主题呢? 一探 React Na...

第六章 之五

OK,说好本章结要来说一下页面,那就说一下页面能干嘛,首先要提到的是有页面功能又有文章功能,同时都可...

前端工程师也能开发全端网页:挑战 30 天用 React 加上 Firebase 打造社群网站|Day16 文章留言区块

连续 30 天不中断每天上传一支教学影片,教你如何用 React 加上 Firebase 打造社群...

虹语岚访仲夏夜-5(专业的小四篇)

有些事    不可跨越 像飞的太靠近太阳  而被融化的翅膀 又或像 艾尔文献出自己的心脏  而领便...