前言
这篇文章主要是因为自己在学习React中setState的时候,产生了一些疑惑,所以进行了一定量的收集资料和学习,并在此记录下来
引入
使用过React的应该都知道,在React中,一个组件中要读取当前状态需要访问this.state,但是更新状态却需要使用this.setState,不是直接在this.state上修改,就比如这样:
//读取状态
const count = this.state.count;
//更新状态
this.setState({count: count + 1});
//无意义的修改
this.state.count = count + 1;
同步和异步
开发中我们并不能直接通过修改state的值来让界面发生更新:
-
因为我们修改了state之后,希望React根据最新的State来重新渲染界面,但是这种方式的修改React并不知道数据发生了变化;
-
React并没有实现类似于Vue2中的Object.defineProperty或者Vue3中的Proxy的方式来监听数据的变化;
-
我们必须通过setState来告知React数据已经发生了变化;
疑惑:在组件中并没有实现setState的方法,为什么可以调用呢?
- 原因很简单,setState方法是从Component中继承过来的
(1)setState异步更新
setState的更新是异步的?
changeText() {
this.setState({
message: "你好啊,李银河"
})
console.log(this.state.message); // Hello World
}
-
最终打印结果是Hello World;
-
可见setState是异步的操作,我们并不能在执行完setState之后立马拿到最新的state的结果
-
为什么setState设计为异步呢?
-
setState设计为异步其实之前在GitHub上也有很多的讨论;
-
React核心成员(Redux的作者)Dan Abramov也有对应的回复,有兴趣的同学可以参考一下;
-
https://github.com/facebook/react/issues/11527#issuecomment-360199710;
我对其回答做一个简单的总结:
setState设计为异步,可以显著的提升性能;
-
如果每次调用 setState都进行一次更新,那么意味着render函数会被频繁调用,界面重新渲染,这样效率是很低的;
-
最好的办法应该是获取到多个更新,之后进行批量更新;
如果同步更新了state,但是还没有执行render函数,那么state和props不能保持同步;
- state和props不能保持一致性,会在开发中产生很多的问题;
(2)如何获取异步的结果
那么如何可以获取到更新后的值呢?
方式一:setState的回调
-
setState接受两个参数:第二个参数是一个回调函数,这个回调函数会在更新后会执行;
-
格式如下:setState(partialState, callback)
this.setState({
message: "你好啊,李银河"
}, () => {
console.log(this.state.message);
})
方式二:在生命周期函数内获取
componentDidUpdate() {
// 方式二: 获取异步更新的state
console.log(this.state.message);
}
(3)setState一定是异步吗?
其实分成两种情况:
-
在组件生命周期或React合成事件中,setState是异步;
-
在setTimeout或者原生dom事件中,setState是同步;
验证一:在setTimeout中的更新:
changeText() {
// 情况一: 将setState放入到定时器中
setTimeout(() => {
this.setState({
message: "你好啊,李银河"
})
console.log(this.state.message); // 你好啊,李银河
}, 0);
}
验证二:原生DOM事件:
componentDidMount() {
document.getElementById("btn").addEventListener("click", (e) => {
this.setState({
message: "你好啊,李银河"
})
console.log(this.state.message);
})
// this.setState({
// message: "你好啊,李银河"
// })
// console.log(this.state.message);
}
合并
然后我们来看一道题目:
function incrementMultiple() {
this.setState({count: this.state.count + 1});
this.setState({count: this.state.count + 1});
this.setState({count: this.state.count + 1});
}
请问这个时候,this.state.count的值为多少呢?答案为1
浅析
数据的合并
Object.assign({}, prevState, partialState)
// 也就是说
this.setState({Age: '22'})
this.setState({Name: 'srtian'})
// 等价于
this.setState({Age: '22', Name: 'srtian})
this.state = {
message: "Hello World",
name: "wyx"
}
this.setState({
message: "你好啊,帅哥"
});
通过setState去修改message,是不会对name产生影响的;
源码中其实是有对 原对象 和 新对象进行合并的:
setState本身的合并
this.setState会通过引发一次组件的更新过程来引发重新绘制。也就是说setState的调用会引起React的更新生命周期的四个函数的依次调用:
- shouldComponentUpdate
- componentWillUpdate
- render
- componentDidUpdate
我们都知道,在React生命周期函数里,以render函数为界,无论是挂载过程和更新过程,在render之前的几个生命周期函数,this.state和Props都是不会发生更新的,直到render函数执行完毕后,this.state才会得到更新。(有一个例外:当shouldComponentUpdate函数返回false,这时候更新过程就被中断了,render函数也不会被调用了,这时候React不会放弃掉对this.state的更新的,所以虽然不调用render,依然会更新this.state。)
React的官方文档有提到过这么一句话:
状态更新会合并(也就是说多次setstate函数调用产生的效果会合并)。
看源码
ReactComponent.prototype.setState = function(partialState, callback) {
invariant(
typeof partialState === 'object' ||
typeof partialState === 'function' ||
partialState == null,
'setState(...): takes an object of state variables to update or a ' +
'function which returns an object of state variables.',
);
this.updater.enqueueSetState(this, partialState);
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState');
}
};
enqueueSetState: function(publicInstance, partialState) {
if (__DEV__) {
ReactInstrumentation.debugTool.onSetState();
warning(
partialState != null,
'setState(...): You passed an undefined or null state object; ' +
'instead, use forceUpdate().',
);
}
var internalInstance = getInternalInstanceReadyForUpdate(
publicInstance,
'setState',
);
if (!internalInstance) {
return;
}
var queue =
internalInstance._pendingStateQueue ||
(internalInstance._pendingStateQueue = []);
queue.push(partialState);
enqueueUpdate(internalInstance);
}
// 通过enqueueUpdate执行state更新
function enqueueUpdate(component) {
ensureInjected();
// batchingStrategy是批量更新策略,isBatchingUpdates表示是否处于批量更新过程
// 最开始默认值为false
if (!batchingStrategy.isBatchingUpdates) {
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
dirtyComponents.push(component);
if (component._updateBatchNumber == null) {
component._updateBatchNumber = updateBatchNumber + 1;
}
}
// 对_pendingElement, _pendingStateQueue, _pendingForceUpdate进行判断,
// _pendingStateQueue由于会对state进行修改,所以不为空,
// 然后会调用updateComponent方法
performUpdateIfNecessary: function(transaction) {
if (this._pendingElement != null) {
ReactReconciler.receiveComponent(
this,
this._pendingElement,
transaction,
this._context,
);
} else if (this._pendingStateQueue !== null || this._pendingForceUpdate) {
this.updateComponent(
transaction,
this._currentElement,
this._currentElement,
this._context,
this._context,
);
} else {
this._updateBatchNumber = null;
}
},
其中这段代码需要额外注意:
// batchingStrategy是批量更新策略,isBatchingUpdates表示是否处于批量更新过程
// 最开始默认值为false
if (!batchingStrategy.isBatchingUpdates) {
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
dirtyComponents.push(component);
if (component._updateBatchNumber == null) {
component._updateBatchNumber = updateBatchNumber + 1;
}
上面这段代码的意思就是如果是处于批量更新模式,也就是isBatchingUpdates为true时,不进行state的更新操作,而是将需要更新的component添加到dirtyComponents数组中。
如果不处于批量更新模式,则对所有队列中的更新执行batchedUpdates方法。
然后可以找到了这个batchedUpdates:
var ReactDefaultBatchingStrategy = {
// 也就是上面提到的默认为false
isBatchingUpdates: false,
// 这个方法只有在isBatchingUpdates: false时才会调用
// 但一般来说,处于react大事务中时,会在render中的_renderNewRootComponent中将其设置为true。
batchedUpdates: function(callback, a, b, c, d, e) {
var alreadyBatchingUpdates = ReactDefaultBatchingStrategy.isBatchingUpdates;
ReactDefaultBatchingStrategy.isBatchingUpdates = true;
// The code is written this way to avoid extra allocations
if (alreadyBatchingUpdates) {
return callback(a, b, c, d, e);
} else {
return transaction.perform(callback, null, a, b, c, d, e);
}
},
即:
当我们调用setState时,最终会通过enqueueUpdate执行state更新,就像上面那样有两种更新的模式,一种是批量更新模式,将组建保存在dirtyComponents;另一种非批量模式,将会遍历dirtyComponents,对每一个dirtyComponents调用updateComponent方法。
原理图
原理可以用这张图来描述,即在react中,setState通过一个队列机制实现state的更新。当执行setState时,会把需要更新的state合并后放入状态队列,而不会立刻更新this.state,当进入组件可更新状态时,这个队列机制就会高效的批量的更新state。
partialState:setState传入的第一个参数,对象或函数
_pendingStateQueue:当前组件等待执行更新的state队列
isBatchingUpdates:react用于标识当前是否处于批量更新状态,所有组件公用
dirtyComponent:当前所有处于待更新状态的组件队列
transcation:react的事务机制,在被事务调用的方法外包装n个waper对象,并一次执行:waper.init、被调用方法、waper.close
FLUSH_BATCHED_UPDATES:用于执行更新的waper,只有一个close方法
执行过程
对照上面流程图的文字说明,大概可分为以下几步:
1.将setState传入的partialState参数存储在当前组件实例的state暂存队列中。
2.判断当前React是否处于批量更新状态,如果是,将当前组件加入待更新的组件队列中。
3.如果未处于批量更新状态,将批量更新状态标识设置为true,用事务再次调用前一步方法,保证当前组件加入到了待更新组件队列中。
4.调用事务的waper方法,遍历待更新组件队列依次执行更新。
5.执行生命周期componentWillReceiveProps。
6.将组件的state暂存队列中的state进行合并,获得最终要更新的state对象,并将队列置为空。
7.执行生命周期componentShouldUpdate,根据返回值判断是否要继续更新。
8.执行生命周期componentWillUpdate。
9.执行真正的更新,render。
10.执行生命周期componentDidUpdate。
总结
1.钩子函数和合成事件中:
在react的生命周期和合成事件中,react仍然处于他的更新机制中,这时isBranchUpdate为true。
按照上述过程,这时无论调用多少次setState,都会不会执行更新,而是将要更新的state存入_pendingStateQueue,将要更新的组件存入dirtyComponent。
当上一次更新机制执行完毕,以生命周期为例,所有组件,即最顶层组件didmount后会将isBranchUpdate设置为false。这时将执行之前累积的setState。
也就是前言中的那题的来源
2.异步函数和原生事件中
由执行机制看,setState本身并不是异步的,而是如果在调用setState时,如果react正处于更新过程,当前更新会被暂存,等上一次更新执行后在执行,这个过程给人一种异步的假象。
在生命周期,根据JS的异步机制,会将异步函数先暂存,等所有同步代码执行完毕后在执行,这时上一次更新过程已经执行完毕,isBranchUpdate被设置为false,根据上面的流程,这时再调用setState即可立即执行更新,拿到更新结果。
3.partialState合并机制
我们看下流程中_processPendingState的代码,这个函数是用来合并state暂存队列的,最后返回一个合并后的state。
_processPendingState: function (props, context) {
var inst = this._instance;
var queue = this._pendingStateQueue;
var replace = this._pendingReplaceState;
this._pendingReplaceState = false;
this._pendingStateQueue = null;
if (!queue) {
return inst.state;
}
if (replace && queue.length === 1) {
return queue[0];
}
var nextState = _assign({}, replace ? queue[0] : inst.state);
for (var i = replace ? 1 : 0; i < queue.length; i++) {
var partial = queue[i];
_assign(nextState, typeof partial === 'function' ? partial.call(inst, nextState, props, context) : partial);
}
return nextState;
},
我们只需要关注下面这段代码:
_assign(nextState, typeof partial === 'function' ? partial.call(inst, nextState, props, context) : partial);
如果传入的是对象,很明显会被合并成一次:
Object.assign(
nextState,
{index: state.index+ 1},
{index: state.index+ 1}
)
如果传入的是函数,函数的参数preState是前一次合并后的结果,所以计算结果是准确的。
4.componentDidMount调用setstate
在componentDidMount()中,你 可以立即调用setState()。它将会触发一次额外的渲染,但是它将在浏览器刷新屏幕之前发生。这保证了在此情况下即使render()将会调用两次,用户也不会看到中间状态。谨慎使用这一模式,因为它常导致性能问题。在大多数情况下,你可以 在constructor()中使用赋值初始状态来代替。然而,有些情况下必须这样,比如像模态框和工具提示框。这时,你需要先测量这些DOM节点,才能渲染依赖尺寸或者位置的某些东西。
以上是官方文档的说明,不推荐直接在componentDidMount直接调用setState,由上面的分析:componentDidMount本身处于一次更新中,我们又调用了一次setState,就会在未来再进行一次render,造成不必要的性能浪费,大多数情况可以设置初始值来搞定。
当然在componentDidMount我们可以调用接口,再回调中去修改state,这是正确的做法。
当state初始值依赖dom属性时,在componentDidMount中setState是无法避免的。(或者可以使用原生事件监听)
5.componentWillUpdate componentDidUpdate这两个生命周期中不能调用setState。
由上面的流程图很容易发现,在它们里面调用setState会造成死循环,导致程序崩溃。
最后再看一道常见面试题
class Example extends React.Component{
constructor(){
super(...arguments)
this.state = {
count: 0
};
}
componentDidMount(){
// a
this.setState({count: this.state.count + 1});
console.log('1:' + this.state.count)
// b
this.setState({count: this.state.count + 1});
console.log('2:' + this.state.count)
setTimeout(() => {
// c
this.setState({count: this.state.count + 1});
console.log('3:' + this.state.count)
}, 0)
// d
this.setState(preState => ({ count: preState.count + 1 }), () => {
console.log('4:' + this.state.count)
})
console.log('5:' + this.state.count)
// e
this.setState(preState => ({ count: preState.count + 1 }))
console.log('6:' + this.state.count)
}
}
思考一下,你的答案是什么???
你的答案是否正确?你又是否理解为什么会出现上面的答案?接下来我们就来仔细分析一下。
setState(updater, [callback])
setState 可以接受两个参数,第一个参数可以是一个对象或者是一个函数,都是用来更新 state。第二个参数是一个回调函数(相当于Vue中的$NextTick ),我们可以在这里拿到更新的 state。
在上面的代码中,【a,b,c】的 setState 的第一个参数都是一个对象,【e,f】的 setState 的第一个参数都是函数。
首先,我们先说说执行顺序的问题。
【1,2,5,6】最先打印,【4】在中间,最后打印【3】。因为【1,2,5,6】是同步任务,【4】是回调,相当于 NextTick 微任务,会在同步任务之后执行,最后的【3】是宏任务,最后执行。
接下来说说打印的值的问题。
在【1,2,5,6】下面打印的 state 都是0,说明这里是异步的,没有获取到即时更新的值;
在【4】里面为什么打印出3呢?
首先在【a,b】两次 setState 时,都是直接获取的 this.state.count 的值,我们要明白,这里的这个值有“异步”的性质(这里的“异步”我们后面还会讲到),异步就意味着这里不会拿到能即时更新的值,那每次 setState 时,拿到的 this.state.count 都是0。
在【d,e】两个 setState 时,它的参数是函数,这个函数接收的第一个参数 preState (旧的 state ),在这里是“同步”的,虽有能拿到即时更新的值,那么经过【a,b】两次 setState (这里类似于被合并),这里即时的 count 还是1。因为上面我们说过的执行顺序的关系,再经过【d,e】两次 setState ,所以 count 变成了3。
那么在【3】中打印出4又是为什么?你不是说了在 this.state.count 中拿到的值是“异步”的吗,不是应该拿到0吗,怎么会打印出4呢?
method() {
isBatchingUpdate = true;
// 你需要执行的一些代码
// ...
isBatchingUpdate = false
}
那么在上面的那个面试题中,在 setTimeout 执行的时候 isBatchingUpdate 是 false ,没有命中 batchUpdate 机制,所有同步更新,这里的 this.state.count 已经是 3 了,所有在【3】中打印的就是 4。
componentDidMount(){
isBatchingUpdate = true
setTimeout(() => {
// c
// 由于执行顺序的原因,在这里 isBatchingUpdate 已经是 false 了,所以同步更新
this.setState({count: this.state.count + 1});
console.log('3:' + this.state.count)
}, 0)
isBatchingUpdate = false
}
以上是这个面试题的问题。还有一些 react 中自定义的 DOM 事件,同样是异步代码,也遵循这个 batchUpdata 机制,明白了这其中的原理,啥面试题都难不住我们。
那么接下来我们做下总结:
this.state是否异步,关键是看是否命中 batchUpdata 机制,命中就异步,未命中就同步。
setState 中的 preState 参数,总是能拿到即时更新(同步)的值。