【React源码笔记】setState原理解析
大家都知道React是以数据为核心的,当状态发生改变时组件会进行更新并渲染。除了通过React Redux、React Hook进行状态管理外,还有像我这种小白通过setState进行状态修改。对于React的初学者来说,setState这个API是再亲切不过了,同时也很好奇setState的更新机制,因此写了一篇文章来进行巩固总结setState。
React把组件看成是一个State Machines状态机,首先定义数值的状态state,通过用户交互后状态发生改变,然后更新渲染UI。也就是说更新组件的state,然后根据新的state重新渲染更新用户的界面。而在编写类组件时,通常分配state的地方是construtor函数。
刚开始热情满满学习的时候,总是从React官方文档开始死磕,看到state那一块,官方文档抛出了“ 关于 setState()你应该了解的三件事 “几个醒目的大字:
(1)不要直接修改state (2)state的更新可能是异步的 (3)state的更新会被合并
啊…那setState方法从哪里来?为什么setState是有时候是异步会不会有同步的呢?为什么多次更新state的值会被合并只会触发一次render?为什么直接修改this.state无效???
带着这么多的疑问,因为刚来需求也不多,对setState这一块比较好奇,那我就默默clone了react源码。今天从这四个有趣的问题入手,用setState跟大家深入探讨state的更新机制,一睹setState的芳容。源码地址入口(本次探讨是基于React 16.7.0版本,React 16.8后加入了Hook)。
Component.prototype.setState = function(partialState, callback) {
...
this.updater.enqueueSetState(this, partialState, callback, 'setState');
};
setState是挂载在组件原型上面的方法,因此用class方法继承React.Component时,setState就会被自定义组件所继承。通过调用this就可以访问到挂载到组件实例对象上的setState方法,setState方法从这来。
在react state源码注释中有这样一句话:
There is no guarantee that this.state will be immediately updated, so accessing this.state after calling this method may return the old value.
大概意思就是说setState不能确保实时更新state,但也没有明确setState就是异步的,只是告诉我们什么时候会触发同步操作,什么时候是异步操作。
首先要知道一点,setState本身的执行过程是同步的,只是因为在react的合成事件与钩子函数中执行顺序在更新之前,所以不能直接拿到更新后的值,形成了所谓的“ 异步 ”。异步可以避免react改变状态时,资源开销太大,要去等待同步代码执行完毕,使当前的JS代码被阻塞,这样带来不好的用户体验。
那setState什么时候会执行异步操作或者同步操作呢?
简单来说,由react引发的事件处理都是会异步更新state,如
合成事件(React自己封装的一套事件机制,如onClick、onChange等)
React生命周期函数
而使用react不能控制的事件则可以实现同步更新,如
setTimeout等异步操作
原生事件,如addEventListener等
setState回调式的callback
由上面第一部分的代码可知setState方法传入参数是partialState, callback,partialState是需要修改的setState对象,callback是修改之后回调函数,如 setState({},()=>{})
。我们在调用setState时,也就调用了 this.updater.enqueueSetState
,updater是通过依赖注入的方式,在组件实例化的时候注入进来的,而之后被赋值为classComponentUpdater。而enqueueSetState如其名,是一个队列操作,将要变更的state统一插入队列,待一一处理。enqueueSetState函数如下:
const classComponentUpdater = {
isMounted,
// inst其实就是组件实例对象的this
enqueueSetState(inst, payload, callback) {
// 获取当前实例上的fiber
const fiber = getInstance(inst);
const currentTime = requestCurrentTime();
const expirationTime = computeExpirationForFiber(currentTime, fiber);
const update = createUpdate(expirationTime);
update.payload = payload;
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback, 'setState');
}
update.callback = callback;
}
flushPassiveEffects();
// 把更新放到队列中去
enqueueUpdate(fiber, update);
// 进入异步渲染的核心:React Scheduler
scheduleWork(fiber, expirationTime);
},
...
}
注释中讲到scheduleWork是异步渲染的核心,正是它里面调用了reqeustWork函数。
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
// 根节点添加到调度任务中
addRootToSchedule(root, expirationTime);
if (isRendering) {
return;
}
// isBatchingUpdates默认为flase,但是react事件触发后会对它重新赋值为true
if (isBatchingUpdates) {
// isUnbatchingUpdates默认也为false
if (isUnbatchingUpdates) {
nextFlushedRoot = root;
nextFlushedExpirationTime = Sync;
performWorkOnRoot(root, Sync, false);
}
return;
}
if (expirationTime === Sync) {
// 若是isBatchingUpdates为false,则对setState进行diff渲染更新
performSyncWork();
} else {
scheduleCallbackWithExpirationTime(root, expirationTime);
}
}
可以看到在这个函数中有isRendering(当React的组件正在渲染但还没有渲染完成的时候,isRendering是为true;在合成事件中为false)和isBatchingUpdates(默认为false)两个变量,而这两个变量在下文分析中起到非常重要的作用。
2.1 交互事件里面的setState
举个栗子:
this.state = {
name:'rosie',
age:'21',
};
handleClick(){
this.setState({
age: '18'
})
console.log(this.state.age) // 输出21
}
可以看到在react交互事件里age并没有同步更新。
先贴张小小的流程图:
react有一套自己的事件合成机制,在合成事件调用时会用到interactiveUpdates函数。
function interactiveUpdates<A, B, R>(fn: (A, B) => R, a: A, b: B): R {
if (isBatchingInteractiveUpdates) {
return fn(a, b);
}
...
const previousIsBatchingInteractiveUpdates = isBatchingInteractiveUpdates;
// 将previousIsBatchingUpdates赋值为false
const previousIsBatchingUpdates = isBatchingUpdates;
isBatchingInteractiveUpdates = true;
isBatchingUpdates = true;
// 关键代码块
try {
return fn(a, b);
} finally {
isBatchingInteractiveUpdates = previousIsBatchingInteractiveUpdates;
// isBatchingUpdates变为false
isBatchingUpdates = previousIsBatchingUpdates;
if (!isBatchingUpdates && !isRendering) {
performSyncWork();
}
}
}
可以看到这个函数中执行了 isBatchingUpdates=true
,在执行try代码块中的fn函数(指的是从dispatchEvent 到 requestWork整个调用栈)时,在reqeustWork方法中isBatchingUpdates被修改成了true,而isUnbatchingUpdates默认为false,所以在这里直接被return了。这就表示requestWork中performSyncWork函数没有被执行到,当然其他更新函数像performWorkOnRoot也没有被执行,因此还没被更新。但是在开始的enqueueSetState函数通过 enqueueUpdate(fiber,update)
语句已经把该次更新存入到了队列当中。
if (isBatchingUpdates) {
// isUnbatchingUpdates也为false
if (isUnbatchingUpdates) {
nextFlushedRoot = root;
nextFlushedExpirationTime = Sync;
performWorkOnRoot(root, Sync, false);
}
return;
}
那么在reqeustWork中被return了,会return到哪里呢?从流程图看到很显然是回到了interactiveUpdates这个方法中。因此执行setState后直接console.log是属于try代码块中的执行,由于合成事件try代码块中执行完state后并没有更新(因为没有执行到performSyncWork),因此输出还是之前的值,造成了所谓的“异步”。
等到合成事件执行完后,就进入到了finally,此时isBatchingUpdates变为false,isRendering也为false,二者取反为true则进入到了performSyncWork函数,这个函数会去更新state并且渲染对应的UI。
2.2 生命周期里的setState
this.state = {
name:'rosie',
age:'21',
};
componentDidMount() {
this.setState({
age: '18'
})
console.log(this.state.age) // 21
}
shouldComponentUpdate(){
console.log("shouldComponentUpdate",this.state.age); // 21
return true;
}
render(){
console.log("render",this.state.age); // 18
return{
<div></div>
}
}
getSnapshotBeforeUpdate(){
console.log("getSnapshotBeforeUpdate",this.state.age); // 18
return true;
}
componentDidUpdate(){
console.log("componentDidUpdate",this.state.age);// 18
}
可以看到在componentDidMount输出结果仍然是以前的值。再贴个大大的流程图:
我们一般在componentDidMount中调用setState,当componentDidMount执行的时候,此时组件还没进入更新渲染阶段,isRendering为true,在reqeustWork函数中直接被return掉(输出旧值最重要原因),没有执行到下面的更新函数。等执行完componentDidMount后才去 commitUpdateQueue更新,导致在componentDidMount输出this.state的值还是旧值。
采用程墨大大的图,React V16.3后的生命周期如下:
那么它会经过组件更新的生命周期,会触发Component的以下4个生命周期方法,并依次执行:
shouldComponentUpdate // 旧值
render // 更新后的值
getSnapshotBeforeUpdate // 更新后的值
componentDidUpdate // 更新后的值
componentDidMount生命周期函数是在组件一挂载完之后就会执行,由新的生命周期图可以看到,当shouldComponentUpdate返回true时才会继续走下面的生命周期;如果返回了false,生命周期被中断,虽然不调用之后的函数了,但是state仍然会被更新。
正是在componentDidMount时直接return掉,经过了多个生命周期this.state才得到更新,也就造成了所谓的“异步”。
当然我们也不建议在componentDidMount中直接setState,在 componentDidMount 中执行 setState 会导致组件在初始化的时候就触发了更新,渲染了两遍,可以尽量避免。同时也禁止在shouldComponentUpdate中调用setState,因为调用setState会再次触发这个函数,然后这个函数又触发了 setState,然后再次触发这两个函数……这样会进入死循环,导致浏览器内存耗光然后崩溃。
2.3 setTimeOut中的setState
this.state = {
name:'rosie',
age:'21',
};
componentDidMount() {
setTimeout(e => {
this.setState({
age: '18'
})
console.log(this.state.age) // 18
}, 0)
}
我们都知道JS有event loop事件循环机制。当script代码被执行时,遇到操作、函数调用就会压入栈。主线程若遇到ajax、setTimeOut异步操作时,会交给浏览器的webAPI去执行,然后继续执行栈中代码直到为空。浏览器webAPI会在某个时间内比如1s后,将完成的任务返回,并排到队列中去,当栈中为空时,会去执行队列中的任务。
function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
...
if (isBatchingUpdates) {
...
return;
}
if (expirationTime === Sync) {
performSyncWork();
} else {
scheduleCallbackWithExpirationTime(root, expirationTime);
}
}
当你try代码块执行到setTimeout的时候,此时是把该异步操作丢到队列里,并没有立刻去执行,而是执行interactiveUpdates函数里的finally代码块,而previousIsBatchingUpdates在之前被赋值为false,之后又赋给了isBatchingUpdates,导致isBatchingUpdates变成false。导致最后在栈中执行setState时,也就是执行try代码块中的fn(a,b)时,进入reqeustWork函数中执行了performSyncWork,也就是可以同步更新state。
2.4 原生事件中的setState
原生事件指的是非react合成事件,像 addEventListener()
或者 document.querySelector().onclick()
等这种绑定事件的形式。
this.state = {
name:'rosie',
age:'21',
};
handleClick = () => {
this.setState({
age: '18'
})
console.log(this.state.age) // 18
}
componentDidMount() {
document.body.addEventListener('click', this.handleClick)
}
因为原生事件没有走合成过程,因此在reqeustWork中isRendering为false,isBatchingUpdates为false,直接调用了performSyncWork去更新,所以能同步拿到更新后的state值。
如果每次更新state都走一次四个生命周期函数,并且进行render,而render返回的结果会拿去做虚拟DOM的比较和更新,那性能可能会耗费比较大。像以下这种:
this.state = {
count:0,
};
add = () => {
for ( let i = 0; i < 10000; i++ ) {
this.setState( { count: this.state.count + 1 } );
}
}
如果每次都立马执行的,在短短的时间里,会有10000次的渲染,这显然对于React来说是较大的一个渲染性能问题。那如果我不是10000次,只有两次呢?
add = ()=>{
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
}
没有意外,以上代码还是只执行了一个render,就算不是10000次计算,是2次计算,react为了提升性能只会对最后一次的 setState
进行更新。
React针对 setState 做了一些特别的优化:React 会将多个setState的调用合并成一个来执行,将其更新任务放到一个任务队列中去,当同步任务栈中的所有函数都被执行完毕之后,就对state进行批量更新。
当然你也可以用回调函数拿到每次执行后的值,此时更新不是批量的:
add = () => {
this.setState((preCount)=>({
count: preCount.count + 1
}));
this.setState((preCount)=>({
count: preCount.count + 1
}));
}// 输出2
你也可以使用setTimeout更新多次:
add = () => {
setTimeout( _=>{
this.setState({
count: this.state.count + 1
});
},0)
setTimeout( _=>{
this.setState({
count: this.state.count + 1
});
},0)
}// 输出2
你上面说了setState会进行批量更新,那为啥使用回调函数或者setTimeout等异步操作能拿到2,也就是render了两次呢??
首先只render一次即批量更新的情况,由合成事件触发时,在reqeustWork函数中isBatchingUpdates将会变成true,isUnbatchingUpdates为false则直接被return掉了。但是之前提到它会在开始的enqueueSetState函数通过enqueueUpdate(fiber, update)已经把该次更新存入到了队列当中,在enqueueUpdate函数中传入了fiber跟update两个参数。
enqueueSetState(inst, payload, callback) {
// 获取当前实例上的fiber
const fiber = getInstance(inst);
// 计算当前时间
const currentTime = requestCurrentTime();
// 计算当前fiber的到期时间,为计算优先级作准备
const expirationTime = computeExpirationForFiber(currentTime, fiber);
// 创建更新一个update
const update = createUpdate(expirationTime);
// payload是要更新的对象
update.payload = payload;
// callback回调函数
if (callback !== undefined && callback !== null) {
if (__DEV__) {
warnOnInvalidCallback(callback, 'setState');
}
update.callback = callback;
}
flushPassiveEffects();
// 重点:把更新放到队列中去
enqueueUpdate(fiber, update);
// 进入异步渲染的核心:React Scheduler
scheduleWork(fiber, expirationTime);
},
简单提一下,为了避免更新的过程中长时间阻塞主线程,在React 16之后加入了Fiber架构,它能将整个更新任务拆分为一个个小的任务,并且能控制这些任务的执行。而fiber是一个工作单元,是把控这个拆分的颗粒度的数据结构。
加入Fiber架构后,react在任务调度之前通过enqueueUpdate函数调度,里面修改了Fiber的updateQueue对象的任务,即维护了fiber.updateQueue,最后调度会调用一个getStateFromUpdate方法来获取最终的state状态,而这个方法里面的这段代码显得尤为关键:
function getStateFromUpdate<State>(
workInProgress: Fiber,
queue: UpdateQueue<State>,
update: Update<State>,
prevState: State,
nextProps: any,
instance: any,
): any {
switch (update.tag) {
...
case UpdateState: {
const payload = update.payload;
let partialState;
// 当payload为函数类型时
if (typeof payload === 'function') {
...
partialState = payload.call(instance, prevState, nextProps);
...
}
...
// 重点:通过Object.assign生成一个全新的state,和未更新的部分state进行合并
return Object.assign({}, prevState, partialState);
}
...
}
return prevState;
}
看到Object.assign是不是很熟悉?preState是原先的状态,partialState是将要更新后的状态,Object.assign就是对象合并。那么 Object.assign({},{count:0},{count:1})
最后返回的是{count:1}达到了state的更新。
我们刚才花了一大篇幅来证明在react合成事件和生命周期下state的更新是异步的,主要体现在interactiveUpdates函数的try finally模块,在try模块执行时不会立刻更新,因此导致三次setState的prevState值都是0,两次setState的partialState都是1。执行两次 Object.assign({},{count:0},{count:1})
最后结果不还是返回1吗?
因此也可以得出state的批量更新是建立在异步之上的,那setTimeout同步更新state就导致state没有批量更新,最后返回2。
那callBack回调函数咋就能也返回2呢?我们知道payload的类型是function时,通过 partialState=payload.call(instance,prevState,nextProps)
语句的执行,能获取执行回调函数后得到的state,将其赋值给每次partialState。每次回调函数都能拿到更新后的state值,那就是每次partialState都进行了更新。在进行Object.assign对象合并时,两次prevState的值都是0,而partialState第一次为1,第二次为2,像如下这样:
Object.assign({}, {count:0}, {count:1});
Object.assign({}, {count:0}, {count:2});
也就最后返回了2。所以如果你不想拿到setState批量更新后的值,直接用回调函数就好啦。
this.state.comment = 'Hello world';
直接以赋值形式修改state,不会触发组件的render。
通过上面的分析,可以得出setState本质是通过一个更新队列机制实现更新的,如果不通过setState而直接修改this.state,那么这个state不会放入状态更新队列中,也就不会render,因此修改state的值必须通过setState来进行。
this.setState({
comment: 'Hello world'
})
更新对象:
this.setState(preState=> ({
obj: Object.assign({}, preState.obj, {name: 'Tom'})
}))
this.setState(preState=> ({
obj: {...preState.obj,name:'Tom'}
}))
更新数组:
this.setState((perState)=>{
return {arr:perState.arr.concat(1)}
})
this.setState((perState)=>{
return {arr:[...perState.arr,1]}
})
this.setState((perState)=>{
return {arr:perState.arr.slice(1,4)}
})
注意,不要使用push、pop、shift、unshift、splice等方法修改数组类型的状态,因为这些方法都是在原数组的基础上修改的,返回值不是新的数组,而是返回长度或者修改的数组部分等。而concat、slice、filter会生成一个新的数组。
总结:通过探讨React state的更新机制,更加理解了React深层更新的运作流程。感觉React还是非常的博大精深,希望以后继续探讨下去哈哈哈,欢迎大家批评指正!