React官方文档在描述setState的时候提到了三点注意事项:
- 不要直接修改State
- State的更新可能是异步的
- State的更新会被合并
我们来一一分析为什么有这3点限制。
不要直接修改state
官方文档并没有具体解释不可以直接修改state的原因。只是说明只有构造函数中可以给state直接赋值。如果我们直接修改state,那么组件将不会触发更新。
出于这种设计的原因我理解有这几点:
- state的更新意味着组件的重新渲染,通过统一的函数去执行这个操作可以使得整个过程更加可控;因为React并没有像Vue那样采用数据双向绑定的机制,所以它需要一种方式采集到state的变更;
- React核心思想是不可变数据,setState内部实际会返回一个新的state,如果直接修改state,可能还是同一个引用;
- 由统一的函数去更新state更加利于维护和控制;
- 出于性能原因,React可能会合并多次setState的操作为一次;
其实上面几个原因的的核心原因还是React的设计思想是View=F(state)
。React通过维护一个不可变的state去渲染视图,更新的时候需要对比新旧state,从而知道最小变更,然后去做更新操作。
这里还涉及到一些性能优化,所以提供一个固定改变state的函数,会让它的整个渲染/更新过程变得更加可控。
State的更新可能是异步的
React的解释是出于性能考虑,React可能会将多次调用合并为一次调用。所以我们不应该期望执行setState后能够理解获取更新后的state。
那么具体什么情况下setState是同步的,什么情况下是异步的呢?
先说结论:通过React生命周期函数以及合成事件执行的setState更新是异步的。而通过类似setTimeout定时器,以及dom.addEventListener等原生JS绑定的事件执行的setState是同步的。
如果我们看setState的源码:
ReactComponent.prototype.setState = function(partialState, callback) {
this.updater.enqueueSetState(this, partialState);
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState');
}
};
enqueueSetState: function(publicInstance, partialState) {
var internalInstance = getInternalInstanceReadyForUpdate(
publicInstance,
'setState',
);
var queue =
internalInstance._pendingStateQueue ||
(internalInstance._pendingStateQueue = []);
queue.push(partialState);
enqueueUpdate(internalInstance);
},
function enqueueUpdate(component) {
ensureInjected();
// 这里非常重要,决定其同步更新还是异步更新就是这里判断的
if (!batchingStrategy.isBatchingUpdates) {
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
dirtyComponents.push(component);
if (component._updateBatchNumber == null) {
component._updateBatchNumber = updateBatchNumber + 1;
}
}
复制代码
batchingStrategy.isBatchingUpdates
决定了这一次setState的调用是同步更新state还是异步更新。
batchingStrategy.isBatchingUpdates默认是false。这个值的改变是由batchedUpdates
函数控制的。batchedUpdates会将isBatchingUpdates改为true。那样setState就是异步更新了。
所以如果我们知道什么时候会调用batchedUpdates,我们也就弄清楚了什么时候setState是同步执行,什么时候是异步执行。
这里需要提到一个事务的概念。当一个事务开始的时候,会调用batchedUpdates将isBatchingUpdates设为true,当事务结束的时候,会将isBatchingUpdates设为false。
React的合成事件、生命周期函数在执行的时候都会生成一个事务,所以在这些情况下执行的setState
均表现为异步。
但是如果我们通过类似setTimeout
让任务在下一个事件循环中执行,这个时候因为事务已经执行完毕,所以执行setState
的时候表现为同步。
通过document.addEventListener
执行的事件也是一样的原理。
hooks中state的表现
// 场景1
const [count, setCount] = useState(1);
useEffect(() => {
setCount(2);
console.log('count', count); // 1,异步
}, []);
// 场景2
const [count, setCount] = useState(1);
useEffect(() => {
setTimeout(() => {
setCount(2);
console.log("count", count); // 1,异步
}, 10);
}, []);
// 场景3
const [count, setCount] = useState(1);
const onClick = () => {
setCount(2);
console.log("count", count); // 1, 异步
};
return <div onClick={onClick}>{count}</div>;
// 场景4
const [count, setCount] = useState(1);
useEffect(() => {
document.getElementById("demo").addEventListener("click", () => {
setCount(2);
console.log("count", count); // 1, 异步
});
});
return <div id="demo">{count}</div>;
复制代码
通过以上4个场景,我们可以看到,通过hooks执行的setState,不管是setTimeout
又或者是addEventListener
这种原生方法,虽然绕过了React事务的过程,但它依然不能立即获取最新的值。究其原因其实我们需要知道函数式组件每次render的实际做了什么。
函数式组件每次render相当于这个函数重新执行了一次,虽然setTimeout是异步执行的,但是因为闭包的原因,它执行的时候拿到的count依然是第一次的值,所以这点它和class组件表现不一样。
State的更新会被合并
这个就比较好理解了,因为state的变更意味着组件需要重新render,为了优化性能,React会收集state的变更,多次调用的情况下,React只会执行一次render。
但如果你按上面说的,在setTimeout
中多次执行setState,那么就会导致多次render,所以日常开发过程中,需要避免这种写法。