闲来聊聊React中的setState

 

前段时间拿到了vivo的前端sp,遗憾的是没拿到ssp,但是vivo的技术大佬给我留下了很深的印象,也让我受益匪浅。

技术面时和大佬聊了一个小时,聊的过程中其实大概也知道了最后的结果;大佬也点出了我的不足,同时他强烈建议和鼓励我去做博客分享,这才有了我这次的第一篇文章。知道了自己的不足以后,最近两个月在疯狂看框架源码,包括React和Vue(主要看React),去学习一下里面的一些奇妙设计思路和编程技巧,感觉收获还是很多的。

由于我自己也就是一个在读研究生,不是什么技术大佬,这也是第一次做博客分享,那么我就选择了一个面试里经常问的问题:setState是同步还是异步?

搞清楚这个问题很有利于我们理解setState的原理。

——————————————————————————正题分割线————————————————————————

由于后面的分析会提及到很多React的源码,可能有的没看过源码的小伙伴就很难接受,那么我就采取先说结果再分析的方法来解释。

 

一般回答的时候,会说React中的setState是异步的;setState它本身的方法调用是同步的,但是调用了setState并不标志着React的state立马就更新了,这个更新是要根据我们当前执行环境的上下文来判断的,如果是处于批量更新的情况下,那么state不是立马更新的,而如果不处于批量更新的情况下,那么它就有可能是立马更新了。为什么说是有可能呢?因为我们现在(React16)是有async model、 就是 concurrent model 这种异步渲染的情况,如果是处于这种情况,state也不是立马就更新的。因为进入异步更新的话,要进入一个异步调度的过程,那么这个过程肯定就不会立马更新state了。

假如面试官继续追问:那么setState什么时候是同步的呢?

答:在React检测不到的地方,例如setInterval, setTimeout里,setState就是同步更新的,还有就是绕过 React 通过 addEventListener 直接添加的事件处理函数也是同步的

 

下面通过源码进行分析:

1、理解setState在流程调度中的位置

首先,setState是创建更新的三种方式中的其中一种,下面这个图是通过源码画出的相关流程。

创建更新以后会进行调度(找到更新对应的FiberRoot节点)=> 讲Root加到Scheduler => ... => 根据expirationTime 来决定进行同步调用还是异步调用,后面就是同步调度工作或者是异步调度工作。

 

也就是说,如果在不看源码的前提下,只需要记住setState是创建更新的开始。中间的过程可能包括了批量处理,而批量处理导致了异步更新,这也是为了做到性能优化而坐的工作。如果想要继续深入理解,源码在所难免。

了方便理解和简化流程,我们默认react内部代码执行到 performWork、performWorkOnRoot、performSyncWork、performAsyncWork这四个方法的时候,就是react去update更新并且作用到UI上。

2、setState在react生命周期和合成事件会批量覆盖执行

合成事件:react为了解决跨平台,兼容性问题,自己封装了一套事件机制,代理了原生的事件,像在jsx中常见的onClick、onChange这些都是合成事件。

先看两个例子,我再给出我自己的理解,我相信自己的理解是OK的,但还请看到的大佬有所指教。

下面先看例子,就很容易看到合成事件批量覆盖的效果。

//函数示例
//假设现在this.state.value = 0;

function eventHandler(){
    this.setState({value:this.state.value + 1});
    this.setState({value:this.state.value + 1});
    this.setState({value:this.state.value + 1});
}

//最后this.state.value仍然会是1,不是3;


class App extends Component {
 
  state = { val: 0 }
 
  eventHandler = () => {
    this.setState({ val: this.state.val + 1 })
    console.log(this.state.val) // 输出的是更新前的val --> 0
  }
  render() {
    return (
      <div onClick={this.eventHandler}>
        {`Counter is: ${this.state.val}`}
      </div>
    )
  }

然后是在生命周期中的批量覆盖效果:

class App extends Component {
 
  state = { val: 0 }
 
 componentDidMount() {
    this.setState({ val: this.state.val + 1 })
   console.log(this.state.val) // 输出的还是更新前的值 --> 0
 }
  render() {
    return (
      <div>
        {`Counter is: ${this.state.val}`}
      </div>
    )
  }

 ——————————————————————下面是分析——————————————————————————

根据expirationTime调用performSyncWork还是scheduleCallbackWithExpirationTime,其实就是同步和异步的区别

function requestWork(root: FiberRoot, expirationTime: ExpirationTime) {
  addRootToSchedule(root, expirationTime)
  if (isRendering) {
    // Prevent reentrancy. Remaining work will be scheduled at the end of
    // the currently rendering batch.
    return
  }

  if (isBatchingUpdates) {
    // Flush work at the end of the batch.
    if (isUnbatchingUpdates) {
      nextFlushedRoot = root
      nextFlushedExpirationTime = Sync
      performWorkOnRoot(root, Sync, true)
    }
    return
  }

  if (expirationTime === Sync) {
    performSyncWork()
  } else {
    scheduleCallbackWithExpirationTime(root, expirationTime)
  }
}

function addRootToSchedule(root: FiberRoot, expirationTime: ExpirationTime) {
  // Add the root to the schedule.
  // Check if this root is already part of the schedule.
  if (root.nextScheduledRoot === null) {
    // This root is not already scheduled. Add it.
    root.expirationTime = expirationTime
    if (lastScheduledRoot === null) {
      firstScheduledRoot = lastScheduledRoot = root
      root.nextScheduledRoot = root
    } else {
      lastScheduledRoot.nextScheduledRoot = root
      lastScheduledRoot = root
      lastScheduledRoot.nextScheduledRoot = firstScheduledRoot
    }
  } else {
    // This root is already scheduled, but its priority may have increased.
    const remainingExpirationTime = root.expirationTime
    if (
      remainingExpirationTime === NoWork ||
      expirationTime < remainingExpirationTime
    ) {
      // Update the priority.
      root.expirationTime = expirationTime
    }
  }
}

scheduleCallbackWithExpirationTime是根据时间片来执行任务的,会涉及到requestIdleCallback。下一节会继续往深挖,如果看不懂没关系,可以直接看结尾的简单解释。

3、Scheduler

延续上一节中的scheduleCallbackWithExpirationTime。

React16.5之后把scheduler单独发一个包了,就叫scheduler,从scheduleCallbackWithExpirationTime开始Scheduler就开始工作了。它的核心工作有:

  • 维护时间片
  • 模拟requestIdleCallback
  • 调度列表和超时判断

简单来说,1秒30帧是保证画面更新流畅的保障,对应到时间片就是33ms要工作一帧,假如React渲染已经占用超过35ms,那么就有2ms要拖到下一帧,那么浏览器可以工作的时间就更少了。理想状态是React渲染做完一帧工作少于33ms,接下来让剩下的ms给浏览器工作,那么这时性能就较好。

其中requestWork内的异步调用有scheduleCallbackWithExpirationTime(root, expirationTime),如下所示

function scheduleCallbackWithExpirationTime(
  root: FiberRoot,
  expirationTime: ExpirationTime,
) {
  if (callbackExpirationTime !== NoWork) {
    // A callback is already scheduled. Check its expiration time (timeout).
    if (expirationTime > callbackExpirationTime) {
      // Existing callback has sufficient timeout. Exit.
      return;
    } else {
      if (callbackID !== null) {
        // Existing callback has insufficient timeout. Cancel and schedule a
        // new one.
        cancelDeferredCallback(callbackID);
      }
    }
    // The request callback timer is already running. Don't start a new one.
  } else {
    startRequestCallbackTimer();
  }

  callbackExpirationTime = expirationTime;
  const currentMs = now() - originalStartTimeMs;
  const expirationTimeMs = expirationTimeToMs(expirationTime);
  const timeout = expirationTimeMs - currentMs;
  callbackID = scheduleDeferredCallback(performAsyncWork, {timeout});
}

里面的调度会根据各个node的expirationTime的大小进行排序,形成一个循环封闭的链表。 

接着是ensureHostCallbackIsScheduled 函数,如果已经在调用回调了,就 return,因为本来就会继续调用下去,isExecutingCallback在flushWork的时候会被修改为true

如果isHostCallbackScheduled为false,也就是还没开始调度,那么设为true,如果已经开始了,就直接取消,因为顺序可能变了。

调用requestHostCallback开始调度

开始进入调度,设置调度的内容,用scheduledHostCallback和timeoutTime这两个全局变量记录回调函数和对应的过期时间

调用requestAnimationFrameWithTimeout,其实就是调用requestAnimationFrame在加上设置了一个100ms的定时器,防止requestAnimationFrame太久不触发。

调用回调animtionTick并设置isAnimationFrameScheduled全局变量为true

模拟 requestIdleCallback
因为requestIdleCallback这个 API 目前还处于草案阶段,所以浏览器实现率还不高,所以在这里 React 直接使用了polyfill的方案。

这个方案简单来说是通过requestAnimationFrame在浏览器渲染一帧之前做一些处理,然后通过postMessage在macro task(类似 setTimeout)中加入一个回调,在因为接下去会进入浏览器渲染阶段,所以主线程是被 block 住的,等到渲染完了然后回来清空macro task。

总体上跟requestIdleCallback差不多,等到主线程有空的时候回来调用

后面还有animationTickidleTick,然后是flushWork

flushFirstCallback 
代码太长不放了,他做的事情很简单

如果当前队列中只有一个回调,清空队列 
调用回调并传入deadline对象,里面有timeRemaining方法通过frameDeadline - now()来判断是否帧时间已经到了 
如果回调有返回内容,把这个返回加入到回调队列

———————————————————————痛苦=>舒服分割线————————————————————————

上面看不懂没事,其实只要知道根据expirationTime做个排序,按顺序去一个一个处理就行了。这里要注意,expirationTime的计算做了一个取整数操作,这就可以理解,好几个setState连着操作只做批量处理的原因了。

(a/b)|0
// 可以取整去掉余数

 具体的expirationTime的计算公式可以去看源码,但是这个处理设计我已经讲到了,React 的这个设计相当于抹平了25ms内计算过期时间的误差,那他为什么要这么做呢?或许,这么做是为了让非常相近的两次更新得到相同的expirationTime,然后在一次更新中完成,相当于一个自动的batchedUpdates。

4、总结

其他的,比如原生事件中的setState和setTimeout、setInterval中的setState是同步的这些内容,就不多做解释了。

这篇文章主要是结合了最近对React源码的学习,所以比较多对源码的分析,当然也有一些自己的理解,但并不知道是否完全正确。我也正在继续探寻的过程中,希望看到的大佬给出一些修改指导。

最后总结一下,面对这个setState到底是同步还是异步的问题,可以结合React官方的一句话以及自己的理解来思考:

"setState() does not immediately mutate this.state but creates a pending state transition. Accessing this.state after calling this method can potentially return the existing value. There is no guarantee of synchronous operation of calls to setState and calls may be batched for performance gains"

其他回答就靠自己的修炼了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值