目录
- 一道说起 setState 必考的面试题
- 异步的魅力,批量操作的艺术
- 合成事件
- 同步背后的故事
前言
在上一篇文章【React setState 异步真的只是为了性能吗?】中为大家简述了 React setState 异步的一些更较深层次原因,保持一致性和为以后需的架构升级启动并发更新。文章发出之后,也收到了一位学长的思考,原话是“ 除了这个还可以思考什么是 Web,从最初的顶层设计就不可能是同步的,之后的 Fiber 也是要解决 idle 的问题,最后完成资源的完美调度 ”。这一句话也给出了更深次的见解,在这里感恩,后续会从这些角度深层次的挖掘。昨天聊完 React setState 异步的原因,今天我们来聊聊 React setState 同步异步的魅力。什么时候同步?什么时候异步?为什么会有同步?
1. 一道说起 setState 必考的面试题
这道面试题,不仅仅经常出现在 BAT 大厂的面试中,也出现在各类文章中,如下:
import React from "react";
import "./styles.css";
export default class App extends React.Component{
state = {
count: 0
}
// count +1
increment = () => {
console.log('increment setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
console.log('increment setState后的count', this.state.count)
}
// count +1 三次
triple = () => {
console.log('triple setState前的count', this.state.count)
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
this.setState({
count: this.state.count + 1
});
console.log('triple setState后的count', this.state.count)
}
// count - 1
reduce = () => {
setTimeout(() => {
console.log('reduce setState前的count', this.state.count)
this.setState({
count: this.state.count - 1
});
console.log('reduce setState后的count', this.state.count)
}, 0);
}
render(){
return <div>
<button onClick={this.increment}> +1 </button>
<button onClick={this.triple}> +1 三次 </button>
<button onClick={this.reduce}> -1 </button>
</div>
}
}
测试代码地址:https://codesandbox.io/s/setstate-test-0dzxw
从左往右依次点击三个按钮,如果你能在脑海中快速的得出结果,哪恭喜你,你对 setState 的同步和异步有了一个了解。在我们最开始学习这个 API 的时候就能清楚的知道 setState 是一个异步的方法,当我们执行完 setState 时,并不会马上去触发状态的更新,所以在 increment 函数中两次输出都是 0 。在 triple 函数中虽然执行了三次 setState,但是批量更新收集三次相同的操作,变成了一个更新操作,在加上 setState 的异步,所以 triple 输出的值,只是在第一步最后变更后的值 1。在来看看第三个函数 reduce ,如果你是一个 React 初学者你可能有一点困惑 setState 竟然是同步更新。不要怀疑他就是同步更新了。哪对于一个老手来说,可能了然于胸,哪为什么会出现有时候是同步更新,有时候又是异步更新,今天我们就来聊聊 setState 异步同步更新的魅力(原理)。
2. 异步的魅力,批量操作的艺术
不管同步异步,setState 在被调用过后, React 做了什么?如果你对 React 版本的更新的历史比较了解,在不同 React 版本 setSatate 触发之后可能会存在一些小的差异,但是整体的思路是一样的。
- React15
- 触发 setState
- shouldComponentUpdate
- componentWillUpdate
- render
- componentDidUpdate
- React16.3
- 触发 setState
- shouldComponentUpdate
- render
- getSnpshotBeforeUpdate
- componentDidUpdate
- React16.4
- 触发 setState
- getDerivedStateFromProps
- shouldComponentUpdate
- render
- getSnapshotBeforeUpdate
- componentDidUpdate
从这个简易的流程图可以看到,当触发 setState 之后,会有一个完整的更新流程,涉及了包括 re-render(重渲染) 在内的多个步骤。re-render 本身涉及对 DOM 的操作,它会带来较大的性能开销。假如说“一次 setState 就触发一个完整的更新流程”这个结论成立,那么每一次 setState 的调用都会触发一次 re-render,我们的视图很可能没刷新几次就卡死了。
this.setState({
count: this.state.count + 1
}); // 触发re-render
this.setState({
count: this.state.count + 1
}); // 触发re-render
this.setState({
count: this.state.count + 1
}); // 触发re-render
...
// 页面卡死
说到这里你可能就知道为什么 setState 需要批量操作了。一个重要的原因就是,避免频繁的重渲染。他内部的机制和 Vue 的 $nextTick 和浏览器的Event-loop 有点类似,多个 setState 执行,就把它塞进一个队列里存储起来,等到这次操作(当前的同步操作)完成,在将在队列中存储的状态(state)做合并,所以无论你执行多少次 setState ,最后只会针对最新的 state 值走一次更新流程,这就是批量操作更新。
this.setState({
count: this.state.count + 1
});
// 进入队列[count + 1]
this.setState({
count: this.state.count + 1
});
// 进入队列[count + 1, count + 1]
this.setState({
count: this.state.count + 1
});
// 进入队列[count + 1, count + 1, count + 1]
...
// 合并state[count + 1]
// 执行count + 1
看到这里你可能已经对 React 的异步更新、批量更新有一定的了解,接着往下看,好戏还在后面。
3. 合成事件
在分析同步场景之前,需要先补充一个很重要的知识点,即React 的合成事件,同样它也是 React 面试中很容易被考察的点,本文只是抛砖引玉简述React 合成事件,后面会专门写一篇文章来说说 React 的合成事件。
在说合成事件之前,我们先说说我们最原始的事件委托,事件委托出现的目的更多的是为了性能考虑,举个例子:
<div>
<div onclick="geText(this)">text 1</div>
<div onclick="geText(this)">text 2</div>
<div onclick="geText(this)">text 3</div>
<div onclick="geText(this)">text 4</div>
<div onclick="geText(this)">text 5</div>
// ... 16~9999
<div onclick="geText(this)">text 10000</div>
</div>
假设一个大的 div 标签下面有 10000 个 小的 div 标签。现在需要添加点击事件,通过点击获取当前 div 标签中的文本。那该如何操作?最简单的操作就是为每一个 内部div 标签添加 onclick 事件。有 10000 个 div 标签,则会添加 10000 个事件。这是一种非常不友好的方式,会对页面的性能产生影响。所以事件委托起了大作用。通过将事件绑定在 外面大的div 标签上这样的方式来解决。当 内部 div 点击时,由事件冒泡到父级的标签去触发,并在标签的 onclick 事件中,确认是哪一个标签触发的点击事件。
无独有偶,React 的合成事件也是如此,React 给 document 挂上事件监听;DOM 事件触发后冒泡到 document;React 找到对应的组件,造出一个合成事件出来;并按组件树模拟一遍事件冒泡。这样就有一个问题,就是在一个页面中,只能有一个版本的 React。如果有多个版本,事件就乱套了。
但是在17版本之后,这个问题得到了解决,事件委托不在挂载到 document 上,而是挂在 DOM 容器上,也就是 ReactDom.Render 所调用的节点上。
合成事件与 setState 的触发更新有千丝万缕的关系,也只有在了解合成事件后,我们才能继续聊 同步 setState。
4. 同步背后的故事
回到之前的例子,setState 在 setTimeout 函数的“包谷”之下,有了同步这一“功能”。为什么 setTimeout 可以将 setState 的执行顺序从异步变为同步?
setState 的工作机制
从源码的角度,我们来看看 setState 到底怎么工作的,注意源码 React 版本是 React 15(明古通今)。
// 入口
ReactComponent.prototype.setState = function (partialState, callback) {
this.updater.enqueueSetState(this, partialState);
if (callback) {
this.updater.enqueueCallback(this, callback, 'setState');
}
};
enqueueSetState: function (publicInstance, partialState) {
// 获取组件实例
var internalInstance = getInternalInstanceReadyForUpdate(publicInstance, 'setState');
// 这个 queue 对应的就是一个组件实例的 state 数组
var queue = internalInstance._pendingStateQueue || (internalInstance._pendingStateQueue = []);
// 将新的 state 放进组件的状态队列里
queue.push(partialState);
// enqueueUpdate 用来处理当前的组件实例
enqueueUpdate(internalInstance);
}
function enqueueUpdate(component) {
ensureInjected();
// 注意这一句是问题的关键,isBatchingUpdates标识着当前是否处于批量创建/更新组件的阶段
if (!batchingStrategy.isBatchingUpdates) {
// 若当前没有处于批量创建/更新组件的阶段,则立即更新组件
batchingStrategy.batchedUpdates(enqueueUpdate, component);
return;
}
// 否则,先把组件塞入 dirtyComponents 队列里,让它“再等等”
dirtyComponents.push(component);
if (component._updateBatchNumber == null) {
component._updateBatchNumber = updateBatchNumber + 1;
}
}
var ReactDefaultBatchingStrategy = {
// 全局唯一的锁标识
isBatchingUpdates: false,
// 发起更新动作的方法
batchedUpdates: function(callback, a, b, c, d, e) {
// 缓存锁变量
var alreadyBatchingStrategy = ReactDefaultBatchingStrategy. isBatchingUpdates
// 把锁“锁上”
ReactDefaultBatchingStrategy. isBatchingUpdates = true
if (alreadyBatchingStrategy) {
callback(a, b, c, d, e)
} else {
// 启动事务,将 callback 放进事务里执行
transaction.perform(callback, null, a, b, c, d, e)
}
}
}
源码中 isBatchingUpdates 属性直接决定了当下是要走更新流程,还是应该排队等待;其中的 batchedUpdates 方法更是能够直接发起更新流程。由此我们可以大胆推测,batchingStrategy 或许正是 React 内部专门用于管控批量更新的对象。
isBatchingUpdates 上” 锁 “
isBatchingUpdates 默认是 false ,意味着“当前并未进行任何批量更新操作”。每当 React 调用 batchedUpdate 去执行更新动作时,会先把这个锁给“锁上”(置为 true),表明“现在正处于批量更新过程中”。当锁被“锁上”的时候,任何需要更新的组件都只能暂时进入 dirtyComponents 里排队等候下一次的批量更新,而不能随意“插队”。此处体现的“任务锁”的思想,是 React 面对大量状态仍然能够实现有序分批处理的基石。
在 onClick、onFocus 等事件中,由于合成事件封装了一层,所以可以将 isBatchingUpdates 的状态更新为 true;在 React 的生命周期函数中,同样可以将 isBatchingUpdates 的状态更新为 true。那么在 React 自己的生命周期事件和合成事件中,可以拿到 isBatchingUpdates 的控制权,将状态放进队列,控制执行节奏。而在外部的原生事件中,并没有外层的封装与拦截,无法更新 isBatchingUpdates 的状态为 true。这就造成 isBatchingUpdates 的状态只会为 false,且立即执行。所以在 addEventListener 、setTimeout、setInterval 这些原生事件中都会同步更新。
总结
道理很简单,原理却很复杂。setState 并不是单纯同步/异步的,它的表现会因调用场景的不同而不同:在 React 钩子函数及合成事件中,它表现为异步;而在 setTimeout、setInterval 等函数中,包括在 DOM 原生事件中,它都表现为同步。这种差异,本质上是由 React 事务机制和批量更新机制的工作方式来决定的。