开发中遇到的问题
在一个下拉加载列表的需求中,有以下的代码:
其中,onScroll 是页面滚动事件的回调函数,throttle 是一个节流方法。获取列表的方法在 useEffect 中条件判断后执行,而 currentPage、isBottom、needLoad 都是依赖数组的一部分,它们的状态值改变时,都可能重新获取列表。
最开始 setCurrentPage(currentPage + 1) 是写在 setNeedLoad(true) 后面的,然后出现了一个 bug,就是会用原本的 currentPage 去请求列表,列表数据就出现了问题。
研究了以后发现,其实是因为 throttle 是一个异步执行的方法,而 setState 在其中是同步的,所以此时页面渲染了 3 次,每个状态值改变都执行了一次 useEffect。而在 currentPage 状态值改变前,就通过了条件判断重新获取了列表,所以出现了这个问题。把 setCurrentPage(currentPage + 1) 移到最前面之后,解决了这个问题。
下面就深入说一下 setState 的同步异步。
class 和 hook 中 setState 的同步异步
在 class 和 hook 中 setState 的同步异步表现是一致的。
在生命周期和 React 注册的事件中,setState 是异步的,只渲染一次;在定时器等异步任务和自定义 DOM 事件中,setState 是同步的,每执行一次 setState 都会重新渲染。
class
异步
import React from 'react';
import { Button } from 'antd';
class Demo extends React.Component {
constructor(props) {
super(props);
this.state = {
count: 0,
};
}
handleClick = () => {
this.setState({ count: this.state.count + 1 });
console.log('count: ', this.state.count);
this.setState({ count: this.state.count + 1 });
console.log('count: ', this.state.count);
}
render() {
const { count } = this.state;
console.log('render count: ', count);
return (
<div>
<Button onClick={this.handleClick}>{count}</Button>
</div>
);
};
}
export default Demo;
点击按钮后的打印结果:
- 在生命周期和 React 注册的事件中,setState 是异步的,在 handleClick 中取值 count 时还没有更新,所以最终 count 只加了 1
- 由于 setState 是异步的,所以只执行了一次 render
同步
...
handleClick = () => {
setTimeout(() => {
console.log('count: ', this.state.count);
this.setState({ count: this.state.count + 1 });
console.log('count: ', this.state.count);
this.setState({ count: this.state.count + 1 });
console.log('count: ', this.state.count);
});
}
...
点击按钮后的打印结果:
- 在自定义 DOM 事件和定时器等异步任务中,setState 是同步的,在 handleClick 中取值 count 时已经更新,所以最终 count 加了 2
- 由于 setState 是同步的,所以每一次 setState 都会执行一次 render
hook
异步
import React, { useState } from 'react';
import { Button } from 'antd';
const Demo = () => {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log('count: ', count);
setCount(count + 1);
console.log('count: ', count);
};
console.log('demo count: ', count);
return (
<div>
<Button onClick={handleClick}>{count}</Button>
</div>
);
}
点击按钮后的打印结果:
- 此时,和 class 的情况一样,setCount 是异步的,在 handleClick 中取值 count 时还没有更新,所以最终 count 只加了 1
- 由于 setCount 是异步的,所以只重新渲染了一次
同步
1)setCount 的参数是状态值
...
const handleClick = () => {
setTimeout(() => {
console.log('count: ', count);
setCount(count + 1);
console.log('count: ', count);
setCount(count + 1);
console.log('count: ', count);
});
};
...
点击按钮后的打印结果:
2)setCount 的参数是回调函数
...
const handleClick = () => {
setTimeout(() => {
console.log('count: ', count);
setCount(value => value + 1);
console.log('count: ', count);
setCount(value => value + 1);
console.log('count: ', count);
});
};
...
点击按钮后的打印结果:
3)总结
- 此时,setCount 是同步的,但是和 class 的情况不太一样,因为 setTimeout 是个闭包,所以在 handleClick 中取值 count 是更新前的值
- 当 setCount 的参数是状态值时,因为 count 的取值一直是 0,相当于执行了两次 setCount(1),所以最终 count 只加了 1;当参数是回调函数时,因为 value 是最新的状态值,所以最终 count 加了 2
- 由于是 setCount 是同步的,所以每次 setCount 都重新渲染了一次
setState 的同步异步取决于是否命中 batchUpdate 机制
setState 是通过一个队列机制实现的 state 更新,执行 setState 的时候,会把需要更新的 state 合并后放入状态队列,不会立刻更新 this.state。
setState 的简化调用栈为:
dirtyComponents 为现在 state 已经被更新了的 components。
判断是否处于批量更新模式(即是否命中 batchUpdate 机制)的标识就是 isBatchingUpdates 的值。当 isBatchingUpdates 为 true 时表示处于批量更新模式,state 会被暂存进队列中,模拟出异步的效果;当 isBatchingUpdates 为 false 时,会直接更新 state。
isBatchingUpdates 的值默认为 false,值的改变和事务 transaction 有关。事务开始前 isBatchingUpdates 会置为 true,事务结束后 isBatchingUpdates 会置为 false。
结合上面的例子:
1)
...
handleClick = () => {
// 开始:处于批量更新模式
// isBatchingUpdates 为 true
this.setState({ count: this.state.count + 1 });
console.log('count: ', this.state.count); // count: 0
// 结束
// isBatchingUpdates 为 false
}
...
2)
...
handleClick = () => {
// 开始:处于批量更新模式
// isBatchingUpdates 为 true
setTimeout(() => {
// 执行时 isBatchingUpdates 已经置为了 false
this.setState({ count: this.state.count + 1 });
console.log('count: ', this.state.count); // count: 1
});
// 结束
// isBatchingUpdates 为 false
}
...
所以有了 setState 同步异步的不同情况。
能命中 batchUpdate 机制(即 setState 异步)的情况有:生命周期、React 中注册的事件,总之就是 React 可以“管理”的入口。
不能命中 batchUpdate 机制(即 setState 同步)的情况有:定时器等异步任务、自定义 DOM 事件,总之就是 React “管不到” 的入口。
Legacy 模式、Blocking 模式、Concurrent 模式中 setState 的同步异步情况
- Legacy 模式:
ReactDOM.render(<App />, rootNode)
。这是当前 React app 使用的方式,当前没有计划删除本模式。 - Blocking 模式:
ReactDOM.createBlockingRoot(rootNode).render(<App />)
。目前正在实验中,作为迁移到 Concurrent 模式的第一个步骤。 - Concurrent 模式:
ReactDOM.createRoot(rootNode).render(<App />)
。目前在实验中,未来稳定之后,打算作为 React 的默认开发模式。
在 Legacy 模式中,setState 可能为同步,也可能为异步。使用 ReactDOM.unstable_batchedUpdates 能强制批量更新(不建议)。
在 Blocking 模式和 Concurrent 模式中,setState 一定为异步。
参考
深入剖析setState同步异步机制(没有找到原文出处)
setState异步、同步与进阶
React官网 - Concurrent 模式(实验性)
《深入REACT技术栈》