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

在上个月初的时候,偶然在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到底是同步还是非同步(下)


<<:  Day28 参加职训(机器学习与资料分析工程师培训班),网站设计与网页工程技术

>>:  理解React的setState到底是同步还是非同步(下)

【程序】陷入低潮 转生成恶役菜鸟工程师避免 Bad End 的 30 件事 - 23

https://youtu.be/vpwC347cXog 陷入低潮 了解低潮 专注在可控的短期 充...

Day9:Job vs SupervisorJob

还记得launch 的回传值是 Job 吗?我们可以使用 job 的 cancel() 来取消该 C...

前端工程学习日记24天 codpen 一秒使用css rest <设定完一劳永逸.

附上作业 https://codepen.io/pwbzvqja/pen/XWMdvqz 如图 ...

【Day 03】- 打针!打针!从 R0 注入的那件事!

Agenda 资安宣言 测试环境与工具 学习目标 技术原理与程序码 References 下期预告 ...

[30天 Vue学好学满 DAY17] Event Bus

Event Bus 前面提到了父子元件透过emit & prop进行参数传递,当树状结构逐渐...