这一次彻底搞懂React中的setState在更新状态是同步还是异步的?

1,913 阅读8分钟

问题引入

React中的setState是用来更新状态的重要工具,但是setState是同步的还是异步的,需要我们进行一定的探讨,接下来让我们好好研究研究。

使用setState的两种形式

  1. 函数形式的setState
test1 = () => {
        // 函数形式的setState,函数形式的setState能够接收到两个参数,一个是state,另一个是props
        this.setState(state => ({count: state.count + 1}))
    }
  1. 对象形式的setState
test2 = () => {
        // 对象形式的setState
        const count = this.state.count + 1;
        this.setState({count})
    }

使用过setState之后能否立即获取到状态更新后的值

答案是不能。

test1 = () => {
        // 函数形式的setState,函数形式的setState能够接收到两个参数,一个是state,另一个是props
        this.setState(state => ({count: state.count + 1}))
        console.log('函数形式的setState更新后:',this.state.count);
    }

image.png

如何立即获取到状态更新后的值

使用setState的第二个参数,这个参数接收的是一个回调函数,这个回调函数会在界面渲染之后调用。

test3 = () => {
        this.setState(state => ({count: state.count + 1}),() => {
            console.log('函数形式的setState更新后:',this.state.count);
        });
    }

image.png

setState()更新状态是同步还是异步的?

回到我们要探讨的正题,setState()更新状态时同步的还是异步的?

核心结论:

setState本质是同步执行的,但状态更新是批量异步的。在React 18之前,只在React事件中批量更新;React 18之后,所有场景都自动批量更新(Automatic Batching)。批量更新是为了性能优化,避免多次setState导致多次重渲染。


1. 基本概念

"异步"的表现

  • setState调用后,state不会立即更新
  • 无法在setState后立即拿到新值
  • 多次setState会被合并为一次更新

"同步"的本质

  • setState函数本身是同步执行的
  • 只是状态更新被延迟到合适时机
  • 批量更新是性能优化手段

2. React 18之前的表现

在React事件处理中(异步表现)

  • 点击事件、onChange等
  • 生命周期函数内
  • 多次setState会被批量处理
  • state不会立即更新

在非React事件中(同步表现)

  • setTimeout/setInterval
  • Promise.then
  • 原生DOM事件
  • 每次setState立即更新,立即触发渲染

为什么区别对待?

  • React事件有批量更新机制
  • 非React事件脱离了React的控制
  • React 17及之前无法自动批量处理

3. React 18的改变(Automatic Batching)

所有场景都批量更新

  • setTimeout中的setState也会批量
  • Promise中的setState也会批量
  • 原生事件中的setState也会批量
  • 统一行为,更符合预期

启用方式

  • 使用ReactDOM.createRoot(默认启用)
  • 使用ReactDOM.render(保持旧行为)

如果不想批量更新

  • 使用ReactDOM.flushSync强制同步

4. 批量更新原理

更新队列机制

  • setState不会立即修改state
  • 而是把更新放入队列
  • 在合适时机批量处理队列
  • 合并多个更新,只渲染一次

批量更新时机

  • React 17:仅在React事件中
  • React 18:所有情况下

性能优化

  • 避免频繁渲染
  • 减少DOM操作
  • 提升应用性能

5. 常见问题

问题1:连续setState拿不到最新值

  • 因为state还没更新
  • 解决:使用函数式更新
  • 或在useEffect中访问最新值

问题2:多次setState只更新一次

  • 对象更新会被合并
  • 解决:使用函数式更新

问题3:需要立即拿到新值

  • 不要直接获取,改变思路
  • 使用useEffect监听变化
  • 或用临时变量计算

6. 实际开发建议

推荐做法

  • 依赖上一次state时用函数式更新
  • 需要响应state变化用useEffect
  • 不要在setState后立即读取state
  • 理解批量更新,避免不必要的优化

性能优化

  • 利用批量更新减少渲染
  • 避免在循环中多次setState
  • 大量更新考虑useReducer

常见误区

  • 以为setTimeout中是同步的(React 18后不是)
  • 认为setState完全异步(本质是同步函数)
  • 过度依赖flushSync(破坏批量优化)

7. 与Vue对比

Vue的响应式

  • nextTick后才能拿到更新后的DOM
  • 但可以立即拿到新的响应式值
  • 也是批量更新DOM

React的setState

  • state本身不会立即更新
  • 需要等待批量更新完成
  • 更新后触发重渲染

总结:

setState是同步函数,但状态更新是批量异步的。React 18之前只在React事件中批量,React 18之后所有场景都批量(Automatic Batching)。批量更新是性能优化,避免频繁渲染。开发中要理解这个机制,使用函数式更新依赖前值,用useEffect响应变化,不要试图在setState后立即读取新值。

判断setState()更新状态时异步还是同步的,主要是看执行setState的位置

  1. 在React控制的回调函数中(生命周期钩子,react事件监听回调)这种情况是异步的。
  2. 在非react控制的异步回调函数中(定时器回调/原生事件监听回调/promise回调)这种情况是同步的。

异步举例

  • 在React事件回调函数中使用setState(异步的)
// React事件回调函数中
update1 = () => {
    console.log('React事件回调函数更新之前:',this.state.count);
    this.setState(state => ({count: state.count + 1}))
    console.log('React事件回调函数更新之后:',this.state.count);
}

image.png

  • 在生命周期钩子函数中使用setState(异步的)
// 在生命周期钩子函数中
componentDidMount() {
    console.log('生命周期钩子函数更新之前:',this.state.count);
    this.setState(state => ({count: state.count + 1}))
    console.log('生命周期钩子函数更新之后:',this.state.count);
}

image.png

同步举例

  • setTimeout
// 定时器回调
update2 = () => {
    setTimeout(() => {
        console.log('setTimeout 更新之前:', this.state.count);
        this.setState(state => ({ count: state.count + 1 }))
        console.log('setTimeout 更新之后:', this.state.count);
    })
}

image.png

  • 原生onclick
update3 = () => {
    const h1 = this.refs.count;
    h1.onclick = () => {
        console.log('onclick 更新之前:', this.state.count);
        this.setState(state => ({ count: state.count + 1 }))
        console.log('onclick 更新之后:', this.state.count);
    }
}

image.png

  • Promise
update4 = () => {
    Promise.resolve().then(value => {
        console.log('Promise 更新之前:', this.state.count);
        this.setState(state => ({ count: state.count + 1 }))
        console.log('Promise 更新之后:', this.state.count);
    })
}

image.png

setState多次调用的问题

下面要讨论的多次调用的问题是基于异步的前提下来讨论的。

情况1:两个函数式setState的情况(不会合并)

// 测试函数式 setState合并 与更新的问题
update5 = () => {
    console.log('测试通过函数式更新setState的合并问题 更新之前:', this.state.count);
    this.setState(state => ({ count: state.count + 1 }))
    console.log('测试通过函数式更新setState的合并问题 更新之后:', this.state.count);
    console.log('测试通过函数式更新setState的合并问题 更新之前:', this.state.count);
    this.setState(state => ({ count: state.count + 1 }))
    console.log('测试通过函数式更新setState的合并问题 更新之后:', this.state.count);
}

image.png

情况2:两个对象式setState的情况(会合并)

// 测试对象式 setState合并 与更新的问题
update6 = () => {
    console.log('测试通过对象式更新setState的合并问题 更新之前:', this.state.count);
    this.setState({count: this.state.count + 1})
    console.log('测试通过对象式更新setState的合并问题 更新之后:', this.state.count);
    console.log('测试通过对象式更新setState的合并问题 更新之前:', this.state.count);
    this.setState({count: this.state.count + 1})
    console.log('测试通过对象式更新setState的合并问题 更新之后:', this.state.count);
}

情况3:先函数式后对象式(会合并)

update7 = () => {
    console.log('测试通过函数式更新setState的合并问题 更新之前:', this.state.count);
    this.setState(state => ({ count: state.count + 1 }))
    console.log('测试通过函数式更新setState的合并问题 更新之后:', this.state.count);
    console.log('测试通过对象式更新setState的合并问题 更新之前:', this.state.count);
    this.setState({count: this.state.count + 1})
    console.log('测试通过对象式更新setState的合并问题 更新之后:', this.state.count);
}

image.png

情况4:先对象式后函数式

update7 = () => {
    console.log('测试通过对象式更新setState的合并问题 更新之前:', this.state.count);
    this.setState({count: this.state.count + 1})
    console.log('测试通过对象式更新setState的合并问题 更新之后:', this.state.count);
    console.log('测试通过函数式更新setState的合并问题 更新之前:', this.state.count);
    this.setState(state => ({ count: state.count + 1 }))
    console.log('测试通过函数式更新setState的合并问题 更新之后:', this.state.count);
}

image.png

核心技巧:函数式传入的state总是能够获取到最新的state,但是对象式则不能,但是最后render只会更新一次。

一道经典的setState的面试题(看懂这个,你可能就懂了!)

请问下面的APP组件打印的是什么?

class App extends Component {
    state = {
        count: 0
    }
    // 在生命周期钩子函数中
    componentDidMount() {
        this.setState({ count: this.state.count + 1 })
        this.setState({ count: this.state.count + 1 })
        console.log(this.state.count);

        this.setState(state => ({ count: state.count + 1 }))
        this.setState(state => ({ count: state.count + 1 }))
        console.log(this.state.count);

        setTimeout(() => {
            this.setState(state => ({ count: state.count + 1 }))
            console.log('setTimeout:', this.state.count);

            this.setState(state => ({ count: state.count + 1 }))
            console.log('setTimeout:', this.state.count);
        }, 0)
        Promise.resolve().then(value => {
            this.setState(state => ({ count: state.count + 1 }))
            console.log('Promise',this.state.count);
            this.setState(state => ({ count: state.count + 1 }))
            console.log('Promise:',this.state.count);
        })
    }

    render() {
        const { count } = this.state;
        console.log('render: ', count);
        return (
            <div>
                <h1>当前求和为{count}</h1>
            </div>
        )
    }
}

答案

image.png

答案解析(按输出顺序进行解析)

  1. 第一行: react首先会渲染下组件,此时获取到的count值是state中存的初始值,所以是0.
  2. 第2、3行:执行完render之后,会进入componentDidMount钩子函数,遇到两个对象式的setState会进行合并,但由于此时在钩子函数中,获取state是异步的,所以打印的都是0,但是当遇到函数式的setState,则不会合并,此时count的值已经变为了3.
  3. 第四行:此时componentDidMount中出了Promise和setTimeout外都执行了,上面的代码对JS来说都属于同步代码,此时可以进行更新render了,所以打印了render 3.
  4. 第五行:setTimeout和Promise中,由于Promise是微任务,所以优先执行,在执行的时候,这里的setState是同步更新state的,所以调用一次setState就要调用一次render,所以第五行打印的是render: 4.
  5. 第六行:执行log操作,打印的是Promise: 4。。。

剩下的内容均属于JS事件循环的知识了,如果你有不懂的地方可以参考我的专栏中的事件循环机制的基本认知这篇博文。

codeSandBox