在开发中,我们经常需要在修改 state 的值之后获取到更新后的 state 。但是却发现在使用 setState 之后,通过 this.state.xx 引用的是修改前的值,所以我们就以为 react 的setState 是异步执行的。那么 setState 究竟是否是异步执行的呢? setState 的原理究竟是怎样呢?
setState为什么要异步
通常我们在调用 setState之后无法立马获取最新的 state ,给人的感觉就是 react 是异步去设置状态。那么为什么 react 要把状态的更新设计为异步处理的方式呢?原因可以总结为:
-
保持内部一致性:就算让
setState同步更新,但props并不能同步更新,因为只有当父组件重新渲染-即执行render方法时,子组件才能拿到更新后的props,所以会造成 react 组件内的 state 和 props 不是同一状态下的值。 -
将多次 state 的更新延缓到最后再批量合并处理,然后再统一执行一次渲染,这样处理对与应用的性能优化时有极大好处的。如果每次的状态改变都去重新渲染真实 DOM , 那么会带来巨大的性能消耗。
setState怎么实现异步的
到底是同步还是异步
首先简单介绍下 react 的事件机制: react 为了解决跨平台、兼容性问题,自身封装了一套时间机制,代理了原生的事件,像jsx 语法里的onClick、onChange等事件均是 react 的合成事件。
在 react 中,如果是在 react 的合成事件和组件的生命周期函数内,调用 setState 的话,不会同步的更新 state 状态;除此之外的调用 setState 会同步执行更新 state 的状态。除此之外指的是:1、跳过 react 的合成事件,通过addEventListener 直接添加的事件处理函数。 2、通过setTimeout/setInterval 等产生的异步调用。
为什么会这样
其实 react 在 setState 的实现中,会根据一个变量——isBatchingUpdates 来判断是直接更新 state 的状态,还是将状态更新放到 更新队列 中稍后再处理。而isBatchingUpdates的默认值为false,但是 react 在调用事件处理函数和生命周期函数时,会调用函数 batchedUpdates ,在这个函数内部会将 isBatchingUpdates 设置为true,所以造成的后果就是,所有由 react 控制的事件处理函数以及生命周期函数中调用的 setState 都会走批量更新,也就出现了 state 的异步更新。
总结来说,在 react 内部机制能检测到的地方, setState 就是异步的;在 react 检测不到的地方(如setTimeout/setInterval ), setState 就是同步更新的。
在理解 react 是如何实现上述操作之前,我们需要了解一个概念——事务。
react 中的事务
react 内部实现了一个可供使用的事务,简单来说就是把需要执行的方法用一个容器封装起来,在这个容器内执行方法的前后,分别执行init方法和close方法。 react 的合成事件系统和生命周期就使用了 react 内部实现的事务,为其函数附加了前后两个prev和post钩子。
所以我们可以这样来理解: react 的事件系统和生命周期函数事务的前后的钩子对变量 isBatchingUpdates 做了修改,也就是在prev钩子中将isBatchingUpdates设置为true,然后在post钩子中将isBatchingUpdates设置为false,这样在该事务实现完成之后才会进行 state 状态的更新。
但是如果我们在事务中调用了异步方法,同时在异步方法中调用的 setState,如下代码:
state = { val: 0 }
componentDidMount() {
setTimeout(() => {
this.setState({ val: this.state.val + 1 })
console.log(this.state.val) // 输出更新后的值 --> 1
}, 0)
}
此时的 setState是同步更新的。这是因为 JavaScript 的异步机制,在遇到异步代码时会将异步代码放入到宏任务队列/微任务队列当中,并且在同步代码执行完成之后才会去运行宏任务队列/微任务队列中的代码。所以上述的componentDidMount中,首先会执行事务的prev钩子中将isBatchingUpdates设置为true,然后执行componentDidMount函数,遇到 setTimeout 时将其放到宏任务队列中,随后componentDidMount函数执行完毕后执行post钩子中将isBatchingUpdates设置为false,此时同步代码执行完毕后去执行宏任务队列中的 setTimeout函数,而此时的isBatchingUpdates值为false,所以setTimeout函数中的 setState会同步更新。
源码验证
首先,我们从源码看 setState是如何被赋值:
ReactComponent.prototype.setState = function(partialState, callback) {
// 处理掉入参验证和开发抛错
/**
* 调用setState实际是调用了enqueueSetState
* 调用队列的入队方法,把当前组件的示例和state存进入
*/
this.updater.enqueueSetState(this, partialState);
if (callback) {
// 如果有回调,把回调存进setState队列的后置钩子
this.updater.enqueueCallback(this, callback, 'setState');
}
};
这里会发现调用 setState 实际是调用this.updater.enqueueSetState ,于是我们去看一下 updater 以及 enqueueSetState具体是什么:
function ReactComponent(props, context, updater) {
this.props = props;
this.context = context;
this.refs = emptyObject;
// updater有默认值,真实运行时会注入,其实也算依赖注入
this.updater = updater || ReactNoopUpdateQueue;
}
ReactNoopUpdateQueue 是如下的一个对象,提供了一些方法。
var ReactNoopUpdateQueue={
isMounted: function(publicInstance) {
return false;
},
enqueueCallback: function(publicInstance, callback) { },
enqueueForceUpdate: function(publicInstance) { },
enqueueReplaceState: function(publicInstance, completeState) { },
enqueueSetState: function(publicInstance, partialState) { },
}
但是真实的 updater 其实是 ReactUpdateQueue,它的 enqueueSetState方法定义如下:
// 这个是setState真正调用的函数
enqueueSetState: function(publicInstance, partialState) {
// 忽略基本的容错和抛错
// 存入组件实例,准备更新
var internalInstance = publicInstance;
// 更新队列合并操作 更新 internalInstance._pendingStateQueue
var queue = internalInstance._pendingStateQueue ||(internalInstance._pendingStateQueue = []);
queue.push(partialState);
enqueueUpdate(internalInstance);
},
上述方法将要修改的 state 存入组件实例的 internalInstance 数据中,这也就是前面说过的 更新队列 了。然后调用了全局的方法enqueueUpdate,那么enqueueUpdate里面具体做了什么呢?我们接下来看enqueueUpdate的源码:
function enqueueUpdate(component) {
ensureInjected(); //环境判断:是否有调度事务方法同时有批量更新策略方法
//关键的判断条件,是否是批量更新
//可是isBatchingUpdates这个值谁来维护呢?
if (!batchingStrategy.isBatchingUpdates) { //如果不在批量更新策略中
// 如果不是批量更新,猜想一下,应该会立即更新吧?
batchingStrategy.batchedUpdates(enqueueUpdate, component); // 调用事务
// 对队列中的更新执行 batchedUpdates 方法
return;
}
// 如果是批量更新,那就把组件放入脏组件队列,也就是待更新组件队列
dirtyComponents.push(component);
}
上述方法中出现的 batchingStrategy 就是我们之前说到的 react 内部实现的事务了,
var ReactDefaultBatchingStrategy = {
isBatchingUpdates: false, // 这个就是我们心心念念判断是否在批量更新策略的重要变量。
batchedUpdates: function(callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
ReactDefaultBatchingStrategy.isBatchingUpdates = true;
// 标识着开启批量更新策略
// 第一次进来是false,调用事务
if (alreadyBatchingUpdates) {
callback(a, b, c, d, e);
} else {
transaction.perform(callback, null, a, b, c, d, e);
//进入事务 执行 事件回调 或者 生命周期函数
}
},
};
可以看到 ReactDefaultBatchingStrategy 对象,内部 isBatchingUpdates 初始值为 false,同时搜索整个项目,发现它只有在两个地方被修改:
- 对象自身的 batchedUpdates 方法会固定将
isBatchingUpdates赋值为true,标志着开启批量更新策略。 - 在事务 transaction 的 close 钩子里,
isBatchingUpdates赋值为false。同时这个事务在 transaction 在 batchedUpdates 中执行。
这里我们可以看到,其实 isBatchingUpdates 变量的维护是在 batchedUpdates 方法中.
全局搜索 batchedUpdates,我们发现,react 合成事件和生命周期的装载发生时,调用了batchedUpdates方法,使得内部的同步代码都可以运行在批量更新策略的事务环境中,结束后,调用事务的后置钩子重置 isBatchingUpdates,启动更新。
总结
-
setState只在合成事件和生命周期中是“异步”的,在原生事件和setTimeout中都是同步的。 -
setState的“异步”并不是说内部由异步代码实现,其实本身执行的过程和代码都是同步的,只是合成事件和生命周期函数的调用顺序在更新之前,导致在合成事件和生命周期函数中没法立马拿到更新后的值,形式了所谓的“异步”,当然可以通过第二个参数 setState(partialState, callback) 拿到更新后的结果。 -
setState的批量更新优化也是建立在“异步”( 合成事件和生命周期函数 )之上的,在原生事件和setTimeout 中不会批量更新,在“异步”中如果对同一个值进行多次setState,setState的批量更新策略会对其进行覆盖,取最后一次的执行,如果是同时setState多个不同的值,在更新时会对其进行合并批量更新