前言
本文基于React版本17.0.2,这篇文章会基于Event Loop的机制来分析,对于Event Loop不熟悉的同学可以看我这篇文章从event loop探究javaScript异步。
源码
那我们直接看关键的源码部分,如果感兴趣的同学可以看我这篇文章React源码解析之Scheduler:
function ensureRootIsScheduled(root: FiberRoot, currentTime: number) {
......
// Schedule a new callback.
// 开始调度任务
// 判断新任务的优先级是否是同步优先级
// 是则使用同步渲染模式,否则使用并发渲染模式(时间分片)
let newCallbackNode;
if (newCallbackPriority === SyncLane) {
......
if (supportsMicrotasks) {
if (__DEV__ && ReactCurrentActQueue.current !== null) {
ReactCurrentActQueue.current.push(flushSyncCallbacks);
} else {
scheduleMicrotask(flushSyncCallbacks);
}
} else {
scheduleCallback(ImmediateSchedulerPriority, flushSyncCallbacks);
}
newCallbackNode = null;
} else {
......
//将react与scheduler连接,将react产生的事件作为任务使用scheduler调度
newCallbackNode = scheduleCallback(
schedulerPriorityLevel,
performConcurrentWorkOnRoot.bind(null, root),
);
}
...
}
可以看到首先判断了当前的任务的优先级,如果是同步优先级则则执行if代码块中的代码,而if中根据当前环境是否支持微任务调用了两个方法:scheduleMicrotask
,scheduleCallback
, 在else中也是调用了scheduleCallback
方法:
scheduleMicrotask
这个方法主要作用是将任务添加进微任务队列:
export const scheduleMicrotask: any =
typeof queueMicrotask === 'function'
? queueMicrotask
: typeof localPromise !== 'undefined'
? callback =>
localPromise
.resolve(null)
.then(callback)
.catch(handleErrorInNextTick)
: scheduleTimeout;
可以看到scheduleMicrotask
其实是使用了queueMicrotask
方法,如果queueMicrotask
方法不支持,则会使用Promise
,如果Promise
也不支持,最后会使用setTimeout
来实现。queueMicrotask
是将传入的回调函数添加进微任务队列中,Promise.then
方法也是被添加进微任务队列中,setTimeout
则是宏任务,总的来说scheduleMicrotask
方法是异步的。
scheduleCallback
则是Scheduler
调度任务的入口,而Scheduler
则是使用MessageChannel
来实现的:
let schedulePerformWorkUntilDeadline;
if (typeof localSetImmediate === 'function') {
// Node.js and old IE.
// 使用setImmediate的主要原因是因为在服务端渲染,MessageChannel会阻止nodejs的进程退出
schedulePerformWorkUntilDeadline = () => {
localSetImmediate(performWorkUntilDeadline);
};
} else if (typeof MessageChannel !== 'undefined') {
// 使用MessageChannel的原因是因为
// setTimeout如果嵌套的层级超过了 5 层,并且 timeout 小于 4ms,则设置 timeout 为 4ms。
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
} else {
// We should only fallback here in non-browser environments.
//在以上方案都不能实现的时候,则降级使用setTimeout来实现创建调度者
schedulePerformWorkUntilDeadline = () => {
localSetTimeout(performWorkUntilDeadline, 0);
};
}
可以看到Scheduler根据宿主环境会使用不同的方式实现:
- 在node.js中会使用
setImmediate
来实现 - 在浏览器中会使用
MessageChannel
来实现 - 在以上方案都不能实现的时候,则降级使用setTimeout来实现 在这里我们值讨论浏览器环境,node环境之后再单独来分析。
MessageChannel
是通过实例化,使用消息通道来实现消息传递,是一个宏任务。
setTimeout
也是宏任务,所以Scheduler也是异步的。
例子
接下来,我们举个例子来验证上面的结论。
例子1:
this.state = {num: 0};
function onClick() {
this.setState({
num: this.state.num + 1
});
console.log('num:', this.state.num);
setTimeout(() => {
console.log('num:', this.state.num);
}, 0);
}
在这个例子中,首先调用setState
去更新num的值,紧接着使用console
打印num的值,结果为:0,随后使用了setTimeout
在回调函数中打印num的值,结果为:1,下面我们来分析一下:
- 首先classComponent组件实例化,会将state初始化,num为0
- 然后我们在点击事件中调用了
setState
,之我们分析了setState的实现方式,在Chrome中是支持微任务的,所以会使用queueMicrotask
,将更新任务添加进微任务队列中 - 接着调用了
console
打印num的值,console
是同步代码,所以num的值为初始化的值:0 - 随后使用了
setTimeout
在回调函数中打印num的值,setTimeout
是属于宏任务,会被添加进宏任务队列中。
记得Event Loop的执行机制:当代码运行时(一次事件循环开始),从上到下依次执行,遇到同步代码会马上执行,遇到微任务会将任务添加进微任务队列,在调用栈清空时执行所有的微任务,遇到宏任务,添加进宏任务队列,在下一次事件循环时开始执行(一次事件循环只执行一个宏任务)。
所以上面的执行顺序是:
- 调用了
setState
,将更新任务添加进微任务队列,此时num还未更新为0 - 接着调用了
console
打印num的值:0 - 使用了
setTimeout
,会被添加进宏任务队列中 - 同步代码执行完成,开始执行微任务,也就是
setState
,更新num的值为:1 - 微任务执行完成,浏览器进行页面渲染
- 页面渲染完成,开启新一轮的事件循环,从宏任务队列中取出宏任务,也就是
setTimeout
,执行回调函数,打印出num值为:1
例子2:
class Demo extends Component {
state = { num: 0 }
componentDidMount() {
setTimeout(() => {
this.setState({ val: this.state.num + 1 })
console.log(this.state.num) // 输出更新后的值 --> 0
}, 0)
}
render() {
return (
<div>
{`num is: ${this.state.num}`}
</div>
)
}
}
举这个例子的目的,是因为在之前React 16.xx版本时,在原生事件和 setTimeout
中都是同步的,那时候React的内部实现并不是真正的异步,而现在17.xx版本内部实现都是使用了异步的方式,所以现在不会再出现在不同的地方调用setState,有的地方出现同步,有的地方出现异步的这种情况。
得出结论:setState是异步的。
为什么使用异步?
我们来看个例子:
this.state = {num: 0};
function onClick() {
this.setState({
num: this.state.num + 1
});
this.setState({
num: this.state.num + 2
});
}
我们在onClick事件中连续调用setState
去更新num的值,最终的结果是多少呢?有的同学可能认为是:3,NO,是:2,为啥?
这和React中的Lane机制有关,在React合成事件中连续调用的setState
的优先级是一样的,在第一个setState
调用后,再调用第二个时,会将第一个更新任务的优先级与第二个更新任务的优先级进行比较,如果优先级一样,则不会执行第二个更新任务,而是将第二个任务的更新内容与第一个的更新内容进行合并,最终只会进行一次更新渲染,这样的做法叫做批量更新。
这样做的目的,是为了避免短时间内连续调用setState
造成不必要的渲染,增加性能的开销。
有兴趣的同学可以看我这篇关于优先级调度的文章React源码解析之优先级Lane模型上。
再举个例子:
this.state = {num: 0};
function onClick() {
this.setState({
num: this.state.num + 1
});
this.setState({
num: this.state.num + 2
});
setTimeout(() => {
this.setState({
num: this.state.num + 3
});
console.log('setTimeout=====num:', this.state.num);
}, 0);
}
与上一个例子的不同,是在最后使用setTimeout
调用了setState
,那么这次的结果是多少呢?
揭晓答案:5
有的同学就可能有疑惑了,为什么不是3,而是5,不是说好的批量更新吗?
下面我们来分析一下:
- 首先连续调用了
setState
,我们上面提到过setState
是一个微任务,那么这次连续调用setState
则会被批量更新,只会产生一个微任务,但是更新内容会被合并,而setTimeout
是一个宏任务,会在下一次的事件循环中被执行的,本次事件循环中则只会执行连续调用了setState
产生的微任务,所以结果为:2 - 当执行
setTimeout
中的setState
时,num值已经为2,然后加3,所以结果为5
从这个例子中可以看出,批量更新只会在一个任务中进行,而使用setTimeout
则产生了一个宏任务,事件循环会先让微任务执行,得出结果:2,然后再执行宏任务,得出结果:5,所以使用setTimeout
调用setState
和外部调用的setState
是不会进行批量更新的,这主要是和事件循环的机制有关。
继续看个例子:
this.state = {num: 0};
function onClick() {
this.setState({
num: this.state.num + 1
});
this.setState({
num: this.state.num + 2
});
Promise.resolve().then(() => {
this.setState({
num: this.state.num + 3
});
});
setTimeout(() => {
this.setState({
num: this.state.num + 3
});
console.log('setTimeout=====num:', this.state.num);
}, 0);
}
在上个例子的基础上添加了Promise
,那么这次的结果是多少呢?
答案:8
我们上个例子说说过,批量更新只会在一个任务中执行,连续调用的两个setState
会产生一个微任务,使用Promise
本身就会产生一个微任务,setTimeout
产生一个宏任务,连续调用的两个setState
被批量更新,得出结果:2,然后执行Promise
,得出结果:5,最后执行setTimeout
,得出结果:8。
总结
React的setState是异步的,因为React的渲染机制中使用queueMicrotask
或者MessageChannel
将更新任务添加进微任务队列或者宏任务队列中以此实现异步渲染。
为什么要使用异步渲染?在React中会将连续调用的setState
进行批量更新,这样做的目的,是为了避免短时间内连续调用造成不必要的渲染,增加性能的开销。
批量更新只会在一个微任务或宏任务中进行。