点击上方蓝字,发现更多精彩
导语
大家都知道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)。
1. setState API从哪里来
代码语言:javascript复制Component.prototype.setState = function(partialState, callback) { ... this.updater.enqueueSetState(this, partialState, callback, 'setState');};
setState是挂载在组件原型上面的方法,因此用class方法继承React.Component时,setState就会被自定义组件所继承。通过调用this就可以访问到挂载到组件实例对象上的setState方法,setState方法从这来。
2. setState异步更新 && 同步更新
在react state源码注释中有这样一句话:
代码语言:javascript复制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函数。
代码语言:javascript复制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
举个栗子:
代码语言:javascript复制this.state = { name:'rosie', age:'21',};handleClick(){ this.setState({ age: '18' }) console.log(this.state.age) // 输出21}
可以看到在react交互事件里age并没有同步更新。
先贴张小小的流程图:
react有一套自己的事件合成机制,在合成事件调用时会用到interactiveUpdates函数。
代码语言:javascript复制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
代码语言:javascript复制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个生命周期方法,并依次执行:
代码语言:javascript复制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
代码语言:javascript复制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后,将完成的任务返回,并排到队列中去,当栈中为空时,会去执行队列中的任务。
代码语言:javascript复制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值。
3. setState中的批量更新
如果每次更新state都走一次四个生命周期函数,并且进行render,而render返回的结果会拿去做虚拟DOM的比较和更新,那性能可能会耗费比较大。像以下这种:
代码语言:javascript复制this.state = { count:0,};add = () => { for ( let i = 0; i < 10000; i ) { this.setState( { count: this.state.count 1 } ); }}
如果每次都立马执行的,在短短的时间里,会有10000次的渲染,这显然对于React来说是较大的一个渲染性能问题。那如果我不是10000次,只有两次呢?
代码语言:javascript复制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进行批量更新。
当然你也可以用回调函数拿到每次执行后的值,此时更新不是批量的:
代码语言:javascript复制add = () => { this.setState((preCount)=>({ count: preCount.count 1 })); this.setState((preCount)=>({ count: preCount.count 1 }));}// 输出2
你也可以使用setTimeout更新多次:
代码语言:javascript复制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两个参数。
代码语言:javascript复制 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状态,而这个方法里面的这段代码显得尤为关键:
代码语言:javascript复制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批量更新后的值,直接用回调函数就好啦。
4. 直接修改this.state无效
代码语言:javascript复制this.state.comment = 'Hello world';
直接以赋值形式修改state,不会触发组件的render。
通过上面的分析,可以得出setState本质是通过一个更新队列机制实现更新的,如果不通过setState而直接修改this.state,那么这个state不会放入状态更新队列中,也就不会render,因此修改state的值必须通过setState来进行。
代码语言:javascript复制this.setState({ comment: 'Hello world'})
5. 小Tips && 小总结
更新对象:
代码语言:javascript复制this.setState(preState=> ({ obj: Object.assign({}, preState.obj, {name: 'Tom'})}))this.setState(preState=> ({ obj: {...preState.obj,name:'Tom'}}))
更新数组:
代码语言:javascript复制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还是非常的博大精深,希望以后继续探讨下去哈哈哈,欢迎大家批评指正!
END
▼
更多精彩推荐,请关注我们
▼
你的每个赞和在看,我都喜欢!