从react原理角度理解setState究竟是同步还是异步以及react hooks中的更新机制

不管是面试, 还是在日常开发中, 我们经常会遇到这样一个问题:

setState究竟是同步还是异步, 什么时候同步, 什么时候异步?

简单记忆法

在理解底层原理之前, 通常很多同学是这样记忆的.

  1. 在原生事件window.addEventListener中, setstate是同步更新的
  2. 在回调函数(promise ajax)中是同步更新的
  3. setTimeout 中是同步更新的
  4. async函数(无论await的是同步还是异步, 都会先等待里面执行完毕再执行await后面的代码) 是同步更新的

注意: 在react hooks中, useState不支持同步更新, 如果渲染依赖, 则需要再useEffect中自行处理

原理

  • setState其实没有同步异步这一说
  • 主要是看是否命中batchUpdate机制
  • 判断isBatchingUpdates (false时同步更新)

在react源码中, 维护了一个isBatchingUpdates变量, 用来标识是否批量更新

大体流程如下
image

setState主流程
普通更新

此时setState环境中, isBatchingUpdates = true

//开始: 处于batchUpdate
// isBatchingUpdates = true
this.setState({
    count: this.state.count + 1,
})
//结束
//isBatchingUpdates = false
setTimeout中更新

由js的事件机制我们可以知道, setTimeout的环境中 isBatchingUpdates = false , 所以直接更新

//开始: 处于batchUpdate
// isBatchingUpdates = true
setTimeout(() => {
    //此时isBatchingUpdates是false
    this.setState({
        count: this.state.count + 1,
    })
})
//结束
//isBatchingUpdates = false
原生事件

原理同上

//开始: 处于batchUpdate
// isBatchingUpdates = true
document.body.addEventListener('click',()=>{
    //回调时isBatchingUpdates是false
    this.setState({
        count: this.state.count + 1,
    })
})
//结束
//isBatchingUpdates = false

在react hooks中的更新机制

有这样一个经典的例子
function Parent() {
    let [count, setCount] = useState(0)
    console.log('parent update')
    const handleClick = () => {
        setCount(count + 1)
    }
    return (
        <div onClick={handleClick}>
            Parent clicked {count} times
            <Child />
        </div>
    )
}

function Child() {
    let [count, setCount] = useState(0)
    console.log('child update')
    const handleClick = () => {
        setCount(count + 1)
    }
    return <button onClick={handleClick}>Child clicked {count} times</button>
}

在点击子组件时, 由于事件冒泡机制父组件的点击事件也会触发, 如果不是批量更新, 那么就会

子组件更新 -> 冒泡到父组件 -> 父组件更新 -> 导致子组件更新

就会依次打印
child update -> parent update -> child update

这样就会多一次无效的重复渲染(第一次child update)

我们来看下真正打印出的是什么

parent update
child update

因此 react hooks中也是批量更新(一次收集, 统一渲染)

react hooks中的setTimeout会同步更新吗

上面的例子修改一下

function Parent() {
    let [count, setCount] = useState(0)
    console.log('parent update')
    const handleClick = () => {
        setCount(count + 1)
    }
    return (
        <div onClick={handleClick}>
            Parent clicked {count} times
            <Child />
        </div>
    )
}
function Child() {
    let [count, setCount] = useState(0)
    console.log('child update')
    const handleClick = () => {
        //子组件中的更新设置在setTimeout中, 打印出的会是更新后的值1吗
        setTimeout(() => {
            setCount(count + 1)
            console.log(count)
        }, 0)
    }
    return <button onClick={handleClick}>Child clicked {count} times</button>
}

让我们来看一下打印结果

parent update
child update
child update
0

由打印结果可以看出, setTimeout中的更新只是符合事件机制的执行顺序, 从宏任务中剥离, 走到了下一个宏任务.

具体的执行顺序变成了下面这样

子组件触发handleClick(setTimeout进入宏任务队列) -> 事件冒泡触发父组件的handleClick -> 父组件更新 -> 子组件更新 -> setTimeout开始执行 -> 子组件更新 -> 打印0

再修改上面的代码, 改为如下

function Child() {
    let [count, setCount] = useState(0)
    console.log('child update')
    const handleClick = () => {
        setTimeout(() => {
            //此处setState两次, 会触发两次更新吗
            setCount(count + 1)
            setCount(count + 1)
            console.log(count)
        }, 0)
    }
    return <button onClick={handleClick}>Child clicked {count} times</button>
}

再来看打印结果

parent update
child update
child update
child update
0

事实证明, setTimeout中的渲染确实没有批量更新机制, 每次setState都会更新页面, 但是最后拿到的count却不是更新之后的

我们来看下在useEffectcount的变化

function Child() {
    let [count, setCount] = useState(0)
    console.log('child update')
    useEffect(() => {
        console.log('count', count)
    }, [count])
    const handleClick = () => {
        setTimeout(() => {
            setCount(count + 1)
            setCount(count + 1)
            console.log(count)
        }, 0)
    }
    return <button onClick={handleClick}>Child clicked {count} times</button>
}

猜猜会打印出什么?

parent update //父组件更新
child update  //父组件触发的子组件更新
child update  //子组件setTimeout中第一次更新
count 1       //更新触发了useEffect
child update  //子组件setTimeout中第二次更新
0             //子组件setTimeout中打印出原始count

由这里可以看出, setTimeout中没有触发批量更新机制, 每次setState都会触发re-render

所以, 其实setCount(count + 1) 两次都生效了, 只不过是因为第二次的count没有发生变化, 所以没有触发useEffect

如果代码改成下面这样, 就可以看出

function Child() {
    let [count, setCount] = useState(0)
    console.log('child update')
    useEffect(() => {
        console.log('count', count)
    }, [count])
    const handleClick = () => {
        setTimeout(() => {
            setCount(count + 2) //先+2
            setCount(count + 1) //再+1
            console.log(count)
        }, 0)
    }
    return <button onClick={handleClick}>count is {count}</button>
}

两次都会生效, 第二次会覆盖第一次, 所以最后的count是1

对比一下class组件中的setTimeout
class Child1 extends React.Component {
    constructor() {
        super()
        this.state = {
            count: 0,
        }
    }
    handleClick = () => {
        setTimeout(() => {
            //两次setState, 会合并吗?
            this.setState({
                count: this.state.count + 1,
            })
            this.setState({
                count: this.state.count + 1,
            })
            //最后打印出的是1, 还是2?
            console.log(this.state.count)
        }, 0)
    }
    render() {
        console.log('child1 update')
        return (
            <button onClick={this.handleClick}>
                count is {this.state.count}
            </button>
        )
    }
}

打印结果如下

parent update  //父组件更新
child1 update  //父组件更新触发子组件更新
child1 update  //子组件中的setTimeout第一次setState触发更新
child1 update  //第二次setState触发更新
2  //最后的打印结果

所以, 在class组件中setTimeout中是同步更新, 可以立刻拿到更新后的值
但是在react hooks的函数组件中, setTimeout虽然不是批量更新机制, 但是也不是同步的, 无法在后面立刻拿到最新的值

再对比一下setCount中使用箭头函数的情况
function Child() {
    let [count, setCount] = useState(0)
    console.log('child update')
    useEffect(() => {
        console.log('count', count)
    }, [count])
    const handleClick = () => {
        setTimeout(() => {
        	//此处是箭头函数
            setCount((count) => count + 2)
            setCount((count) => count + 1)
            console.log(count)
        }, 0)
    }
    return <button onClick={handleClick}>count is {count}</button>
}

打印结果如下

parent update //父组件更新
child update  //子组件更新
child update  //子组件setTimeout中第一次set更新
count 2       //useEffect触发
child update   //子组件setTimeout第二次更新
0              //setTimeout中的log
count 3        //触发useEffect

我们可以看到, 最大的不同是, 在最后一次useEffect触发的时候count是3, 而上面直接使用setCount(count+1) 这种形式最后的结果是1.

原因

其实这里和react hooks没有关系, 主要原因在于类组件和函数组件

  • 对于class组件来说, 只需要实例化一次, 实例中保存组件的state等状态. 每次更新时只要调用render方法即可.
  • 但是对于function组件来说, 每一次更新都是一次函数的执行过程, 所以在setTimeout时, 此时的上下文环境就是函数组件被调用时的上下文环境, 所以一直都是原来的值
    (不知道这样解释对不对)

上面count的结果是3, 主要原因是setCount 如果没有使用箭头函数的方式, 上下文执行环境是点击之前的函数组件的上下文环境(setTimeout调用的时候count是0, 所以这个函数的上下文环境的count就是0); 而用了箭头函数之后, 重新建了一个自己的上下文环境, 这个上下文环境中的count取的都是函数组件最新的count

为了保存这些信息, react hooks才出现.

那在react hooks中想要立刻拿到新值怎么办?

某些业务场景下, 我们需要立刻就拿到setState后更新的state, 在react Hooks中怎么办呢?

在react hooks中, 我们可以有以下几种方法

  • 通过useEffect 副作用来进行处理
  • 将值作为参数传递给下一个函数
react hooks中的useEffect问题

但是要注意在使用react hooks时会有闭包陷阱问题. 详见下一篇文章

  • 7
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值