点击这里进入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会调用classComponentUpdater的enqueueSetState方法,该方法会调用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会被分别置为RenderContext和CommitContext。所以,只有初次渲染时,才会进入这个逻辑,此后的每次更新,都会进入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来执行performSyncWorkOnRoot,scheduleSyncCallback中还调度了同一个函数,这样不会调用两次吗,对于这个问题,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();
}
}
如果通过promise的then回调调用setState,相当于回调函数自执行,executionContext没有被赋予任何值,所以executionContext是NoContext,所以执行了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就不再是NoContext,scheduleUpdateOnFiber中不会提前开始更新。
总结
从这篇文章,我们可以看到,setState之所以看起来是异步的,并不是使用了宏任务或者微任务,而是用同步代码,实现了异步执行的效果。而具体实现方式,就是通过内部的任务队列,延迟执行更新。