【React源码笔记】setState原理解析

2021-01-29 15:40:18 浏览数 (1)

点击上方蓝字,发现更多精彩

导语

大家都知道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函数如下:

代码语言:javascript复制
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)语句已经把该次更新存入到了队列当中。

代码语言:javascript复制
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()等这种绑定事件的形式。

代码语言:javascript复制
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,像如下这样:

代码语言:javascript复制
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

更多精彩推荐,请关注我们

你的每个赞和在看,我都喜欢!

0 人点赞