文章目录
不管是面试, 还是在日常开发中, 我们经常会遇到这样一个问题:
setState究竟是同步还是异步, 什么时候同步, 什么时候异步?
简单记忆法
在理解底层原理之前, 通常很多同学是这样记忆的.
- 在原生事件
window.addEventListener
中,setstate
是同步更新的 - 在回调函数(
promise ajax
)中是同步更新的 - 在
setTimeout
中是同步更新的 - async函数(无论await的是同步还是异步, 都会先等待里面执行完毕再执行await后面的代码) 是同步更新的
注意: 在react hooks中, useState不支持同步更新, 如果渲染依赖, 则需要再useEffect中自行处理
原理
- setState其实没有同步异步这一说
- 主要是看是否命中batchUpdate机制
- 判断isBatchingUpdates (false时同步更新)
在react源码中, 维护了一个isBatchingUpdates
变量, 用来标识是否批量更新
大体流程如下
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却不是更新之后的
我们来看下在useEffect
中count
的变化
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时会有闭包陷阱问题. 详见下一篇文章