setState为什么是异步的

·  阅读 667

点击这里进入react原理专栏

写这篇文章的灵感来自于今天刚刚看到的一篇文章:React 中 setState 是一个宏任务还是微任务?。这篇文章讲的还挺好的,推荐大家看一看。那么,这里我来说一下自己的理解。

异步现象

看一个简单的例子

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      a: 1
    }
  }

  handleClick = () => {
    console.log('start', this.state.a)
    this.setState(
      {
        a: 2
      },
      () => {
        console.log('set state', this.state.a)
      }
    )
    console.log('end', this.state.a)
  }

  render() {
    return <button onClick={this.handleClick}>click</button>
  }
}
复制代码

输出结果为start 1 -> end 1 -> set state 2

这个结果很明显说明了setState是异步的。

既然已经明确setState是异步的了,那么我们探索一下,setState是宏任务,还是微任务呢?看下面的代码

class App extends Component {
  constructor(props) {
    super(props)
    this.state = {
      a: 1
    }
  }

  handleClick = () => {
    console.log('start', this.state.a)
    // 添加了promise和setTimeout的代码
    Promise.resolve().then(() => console.log('promise'))
    setTimeout(() => {
      console.log('set timeout')
    })
    this.setState(
      {
        a: 2
      },
      () => {
        console.log('set state', this.state.a)
      }
    )
    console.log('end', this.state.a)
  }

  render() {
    return <button onClick={this.handleClick}>click</button>
  }
}
复制代码

输出结果为start 1 -> end 1 -> set state 2 -> promise -> set timeout

可以看到,setState回调的输出在promise之前。纳尼?难道说setState内部实现了比promise优先级还要高的微任务吗?react还偷偷摸摸地实现了一个新的浏览器API?

透过现象看本质

很明显,前面提出的问题是不可能的。那么,setState的输出结果又该作何解释呢?不如来看一下react是如何实现setState的吧。

setState会调用classComponentUpdaterenqueueSetState方法,该方法会调用enqueueUpdate方法,入队一个update对象,之后调用scheduleUpdateOnFiber来调度更新。

不知道classComponentUpdater是什么的同学可以看我的这篇讲解class组件更新原理的文章

scheduleUpdateOnFiber方法源码如下(删除了不重要的代码)

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  // 。。。
  if (lane === SyncLane) {
    if (
    (executionContext & LegacyUnbatchedContext) !== NoContext &&
    (executionContext & (RenderContext | CommitContext)) === NoContext) {
      schedulePendingInteractions(root, lane);

      performSyncWorkOnRoot(root);
    } else {
      ensureRootIsScheduled(root, eventTime);
      schedulePendingInteractions(root, lane);

      if (executionContext === NoContext) {
        resetRenderTimer();
        flushSyncCallbackQueue();
      }
    }
  } else {
    // 。。。
  }
  // 。。。
}
复制代码

由于在react17中,我们并没有使用concurrent mode,所以lane === SyncLane成立,之后看这个判断:

(executionContext & LegacyUnbatchedContext) !== NoContext 
&&(executionContext & (RenderContext | CommitContext)) === NoContext
复制代码

在执行ReactDOM.render方法时,会将executionContext置为LegacyUnbatchedContext,而进入render阶段或者commit阶段时,executionContext会被分别置为RenderContextCommitContext。所以,只有初次渲染时,才会进入这个逻辑,此后的每次更新,都会进入else的逻辑,因此会执行ensureRootIsScheduled方法。

ensureRootIsScheduled代码如下

function ensureRootIsScheduled(root, currentTime) {
  // ...
  if (newCallbackPriority === SyncLanePriority) {
    newCallbackNode = scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
  }
  // ...
}
复制代码

ensureRootIsScheduled代码如下

function scheduleSyncCallback(callback) {
  // Push this callback into an internal queue. We'll flush these either in
  // the next tick, or earlier if something calls `flushSyncCallbackQueue`.
  if (syncQueue === null) {
    syncQueue = [callback]; // Flush the queue in the next tick, at the earliest.
    immediateQueueCallbackNode = Scheduler_scheduleCallback(Scheduler_ImmediatePriority, flushSyncCallbackQueueImpl);
  } else {
    syncQueue.push(callback);
  }

  return fakeCallbackNode;
}
复制代码

可以看出,ensureRootIsScheduled就是利用scheduler模块提供的scheduleCallback方法来调度performSyncWorkOnRoot方法的执行。了解scheduler的同学可能会说了,scheduleCallback使用了MessageChannel来调度任务的执行,那应该是宏任务啊。这一点确实没错,但是在react17中,并不会采用这种方法来调度任务,而是仍然采用了同步任务的方式。

现在回想一下,我们会调用setState的地方一般有两种:在componentDidMount中和事件处理函数中。

componentDidMount中调用setState的情况

先看ReactDOM.render,该方法会调用legacyRenderSubtreeIntoContainer,而这个方法中有这样一段代码

unbatchedUpdates(function () {
  updateContainer(children, fiberRoot, parentComponent, callback);
});
复制代码

看一下unbatchedUpdates的实现

function unbatchedUpdates(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext &= ~BatchedContext;
  executionContext |= LegacyUnbatchedContext;

  try {
    // fn就是updateContainer
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;

    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      resetRenderTimer();
      flushSyncCallbackQueue();
    }
  }
}
复制代码

我们的componentDidMount生命周期钩子就是在fn就是updateContainer中调用的,而setState是在componentDidMount中调用的。也updateContainer执行完后,会进入finally的逻辑,此时executionContext === NoContext成立,执行flushSyncCallbackQueue方法。这里注意scheduleSyncCallback中的这段注释

// Push this callback into an internal queue. We'll flush these either in
// the next tick, or earlier if something calls `flushSyncCallbackQueue`.
复制代码

翻译过来就是,将callback(这里是performSyncWorkOnRoot)放入一个内部队列(这里是全局变量syncQueue),将会在next tick中调度该任务,或者在调用flushSyncCallbackQueue时更早地调度。没错,就是在unbatchedUpdates中调用的flushSyncCallbackQueue

注意,因为此时还是在react的同步代码中,还没进入下一轮事件循环,因此的确是earlier

细心的同学可能发现了,在unbatchedUpdates中调用了flushSyncCallbackQueue来执行performSyncWorkOnRootscheduleSyncCallback中还调度了同一个函数,这样不会调用两次吗,对于这个问题,flushSyncCallbackQueue内部做了处理

function flushSyncCallbackQueue() {
  // 在scheduleSyncCallback中会为immediateQueueCallbackNode赋值
  if (immediateQueueCallbackNode !== null) {
    // 如果immediateQueueCallbackNode存在,则取消这个任务
    var node = immediateQueueCallbackNode;
    immediateQueueCallbackNode = null;
    Scheduler_cancelCallback(node);
  }
  // 执行队列中的任务
  flushSyncCallbackQueueImpl();
}
复制代码

现在,我们知道了,setState内部会将更新的入口函数performSyncWorkOnRoot放入一个任务队列中,等到componentDidMount中的代码执行完毕之后,再执行performSyncWorkOnRoot,而setState的第二个参数的回调函数,就是在这个performSyncWorkOnRoot中执行的,因此执行顺序为:componentDidMount中的代码 -> setState的回调函数,所以setState看起来是异步的。

在事件处理函数中调用setState的情况

从上一节的内容可以看出,让setState“异步”执行的主要原因在于unbatchedUpdates方法,而react在处理事件处理函数时,也使用了相同的方法,比如触发一个点击事件,会触发dispatchEvent方法,该方法会调用batchedEventUpdates$1

function batchedEventUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= EventContext;

  try {
    // fn就是事件处理相关函数
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;

    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      resetRenderTimer();
      flushSyncCallbackQueue();
    }
  }
}
复制代码

可以看出,这里的代码和unbatchedUpdates基本相同。

小结

可以看出,setState内部使用了一个任务队列,当setState执行时,会将触发更新的函数performSyncWorkOnRoot放入一个队列,之后继续执行剩余代码,而setState的回调函数会在performSyncWorkOnRoot中执行,所以setState看起来是异步的,但实际上,setState还是同步任务,只不过内部的执行顺序不是同步的。

不知道大家有没有这样的疑惑:既然已经用scheduler调度performSyncWorkOnRoot了,那为什么还要在batchedEventUpdates$1中调用flushSyncCallbackQueue,来取消调度的任务,然后”手动“执行performSyncWorkOnRoot呢。我想这可能是为未来的concurrent mode做准备吧。

多个setState呢

现在又有了一个问题,如果我们调用了多个setState呢,会触发多次更新吗?用过react的同学应该都知道并不会触发多次更新。那这是为什么呢,来看一下ensureRootIsScheduled方法,在调用scheduleSyncCallback之前,有这样一段代码

if (existingCallbackNode !== null) {
  var existingCallbackPriority = root.callbackPriority;

  if (existingCallbackPriority === newCallbackPriority) {
    return;
  }
  cancelCallback(existingCallbackNode);
}
复制代码

existingCallbackNode就是被调度的任务,当已经存在一个被调度的任务时,如果新任务和老任务的优先级相同,直接返回,就不会再调度一个新任务了。

用其他方式调用setState呢

前面说了在componentDidMount和事件处理函数中调用setState,那么如果是下面的调用方式呢

componentDidMount() {
  delay(1000).then(() => {
    this.setState(
      {
        a: 1
      },
      () => console.log('set state cb')
    )
    console.log('set state1', this.state.a)
    this.setState(
      {
        a: 2
      },
      () => console.log('set state cb')
    )
    console.log('set state2', this.state.a)
  })
}
复制代码

delay函数模拟数据请求,之后调用setState,输出结果为:set state cb -> set state1 1 -> set state cb -> set state2 2。可以看出,这种情况下,setState又变成了“同步”执行了,这又是为什么呢?看一下scheduleUpdateOnFiber

function scheduleUpdateOnFiber(fiber, lane, eventTime) {
  // 。。。
  ensureRootIsScheduled(root, eventTime);
  schedulePendingInteractions(root, lane);

  if (executionContext === NoContext) {
    resetRenderTimer();
    flushSyncCallbackQueue();
  }
}
复制代码

如果通过promisethen回调调用setState,相当于回调函数自执行,executionContext没有被赋予任何值,所以executionContextNoContext,所以执行了flushSyncCallbackQueue,这样,每执行一次setState,就会立即执行开始一次更新,因此上文例子中的输出是同步的。针对这种情况,我们可以使用这个api:unstable_batchedUpdates

function batchedUpdates$1(fn, a) {
  var prevExecutionContext = executionContext;
  executionContext |= BatchedContext;

  try {
    return fn(a);
  } finally {
    executionContext = prevExecutionContext;

    if (executionContext === NoContext) {
      // Flush the immediate callbacks that were scheduled during this batch
      resetRenderTimer();
      flushSyncCallbackQueue();
    }
  }
}
复制代码

这样,executionContext就不再是NoContextscheduleUpdateOnFiber中不会提前开始更新。

总结

从这篇文章,我们可以看到,setState之所以看起来是异步的,并不是使用了宏任务或者微任务,而是用同步代码,实现了异步执行的效果。而具体实现方式,就是通过内部的任务队列,延迟执行更新。

分类:
前端
标签:
分类:
前端
标签:
收藏成功!
已添加到「」, 点击更改