最近笔者腾出了大把的时间,学习了一下React18。
回首React过往的一些重大版本:
- React 16.0(2017年9月):带来了全新的Fiber架构,引入了异步渲染提升性能,奠定了并发模式的基础。
- React 16.8(2019年2月):引入了
Hooks
,允许函数组件中管理状态和副作用。从此函数组件一发不可收拾。 - React 17.0(2020年10月):没有API更新,主要是为了未来平滑过渡18.0以及提升稳定性。
- React 18.0(2022年3月):React18带来了许多新特性,包括
Concurrent Mode
(并发模式)、Automatic batching
(自动批处理)、新的SSR API、新的Suspense、useTransition
等等…
这些新特性是在React18正式发布的,但是看源码的话其实16.13的版本里就已经有了并发模式相关代码。只不过那个时候不稳定,处于开发状态。并且在17版本时,还提供了很多实验性的API。
并发模式是一种底层设计,不单单指一个功能,而是一系列小特性的集合。并发模式的开启方式就是将
ReactDOM.render
改为ReactDOM.createRoot
。
18新特性很多,我们一个一个去讲。本次主要讲解其中的一个:Automatic batching,即自动批处理。官方解释说自动批处理是一次性能提升,更改了批量更新的方式以自动执行更多批处理。
更多批处理?那在React18之前有批处理吗?现在又做了哪些改进?让我们娓娓道来:
什么是批处理?
批处理就是将一组状态更新合并,只触发一次渲染的性能优化手段。比如在一个点击事件里执行多条状态更新:
function App() {
console.log('App rendered');
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
setCount(c => c + 1); // Does not re-render yet
setFlag(f => !f); // Does not re-render yet
// React will only re-render once at the end (that's batching!)
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
示例中有两个状态更新在handleClick
回调里同步执行,如果它们只触发一次渲染,即为批处理。(示例CodeSandbox)。
包括下面的示例,参考的是官方discussion。
React18之前的批处理
上面的示例如果在React17上执行的话,是只触发一次渲染的。说明在17版本已经存在了批处理的能力。但是17版本只对事件处理器中的状态更新进行了批处理。而对于setTimeout
、Promise
、或者是原生dom事件里的状态更新则不会使用批处理。
function handleClick() {
fetchSomething().then(() => {
// React 17 and earlier does NOT batch these because
// they run *after* the event in a callback, not *during* it
setCount(c => c + 1); // Causes a re-render
setFlag(f => !f); // Causes a re-render
});
}
示例中,多加了一个fetch接口并在Promise处理后触发两个状态更新。在17版本上会发现,它触发了两次渲染。(示例CodeSandbox)。
React18的自动批处理
上面示例中运行在React18的并发模式下。不管是未加fetch还是加了fetch的多次状态更新(示例CodeSandbox),运行后都只触发了一次渲染,所以它们的更新都进行了批处理。这就是18版本的自动批处理。
但是要注意的是,如果18版本未开启并发模式。即仍然使用ReactDOM.render
作为渲染入口,则使用的是Legacy
模式。它的表现会和17版本的一致。(示例CodeSandbox)。
批处理原理
接下来我们直接查看React18源码,并将Legacy模式对应为React17的表现。分析下三个问题:
-
为什么Legacy模式下,事件处理器下的更新可以批处理。
-
为什么Legacy模式下,Promise或setTimeout或原生dom事件下的更新不可以批处理。
-
为什么并发模式下,事件处理器、Promise、setTimeout、原生dom事件下的更新都可以批处理。
我们从触发更新的入口开始查看。如果是类组件的话,会调用this.setState
;如果是函数组件,会调用useState
返回的dispatch
函数。这里看哪个都可以,除了第一步不一样,后续流程都一样。我们直接分析类组件的this.setState
入口:
setState
方法会执行到classComponentUpdater
的enqueueSetState
方法。enqueueSetState
方法会创建Update
,请求lane,然后调用scheduleUpdateOnFiber
准备调度更新。
// 简化,去掉不重要的部分
enqueueSetState: function(inst, payload, callback) {
// ...
var lane = requestUpdateLane();
var update = createUpdate(lane);
// ...
if (root !== null) {
scheduleUpdateOnFiber(root, fiber, lane);
}
// ...
}
requestUpdateLane
方法用于获取当前更新的lane。重点是Legacy模式下一定是SyncLane
。并发模式下会读取提前预设的值或根据window.event获取。
function requestUpdateLane(fiber) {
// 非并发模式,一定是SyncLane
if ((mode & ConcurrentMode) === NoMode) {
return SyncLane;
}
// ...
// 先从预设的字段读取lane。由react在部分情况(比如click事件前)下提前设置
var updateLane = getCurrentUpdatePriority();
if (updateLane !== NoLane) {
return updateLane;
}
// 否则根据window.event判断当前lane
var eventLane = getCurrentEventPriority();
return eventLane;
}
scheduleUpdateOnFiber
方法会调用ensureRootIsScheduled
方法发起调度更新,进到第4步。并在结束后,判断是否要刷新Scheduler
的同步callback,进到第5步。
// 简化,去掉不重要的部分
function scheduleUpdateOnFiber(root, fiber, lane) {
// ...
ensureRootIsScheduled(root);
// 同步优先级 && 没有执行上下文 && 非并发模式
if (lane === SyncLane && executionContext === NoContext && (fiber.mode & ConcurrentMode) === NoMode) {
// Scheduler执行高优任务
flushSyncCallbacks();
}
}
ensureRootIsScheduled
方法根据lane判断是否:不需要发起一次新调度;需要取消已调度更新,并发起新调度;不需要取消已调度更新,并发起新调度;发起指定优先级的调度。调度成功后,发起一个微任务处理掉Scheduler高优任务。
function ensureRootIsScheduled(root, currentTime) {
// ...
// 获取本次更新的lanes
var nextLanes = getNextLanes(root, workInProgressRootRenderLanes);
var newCallbackPriority = getHighestPriorityLane(nextLanes);
var existingCallbackPriority = root.callbackPriority;
// 已调度更新的优先级 === 本次更新优先级,不需要再发起一次调度
if (existingCallbackPriority === newCallbackPriority) {
return;
}
// 打断已调度更新
if (existingCallbackNode != null) {
cancelCallback$1(existingCallbackNode);
}
// 本次是同步优先级,则在Scheduler那里调度一次高优渲染任务。
if (newCallbackPriority === SyncLane) {
scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
} else {
// lane转为Scheduler优先级
switch (lanesToEventPriority(nextLanes)) { /* ... */ }
// 在Scheduler那里调度一次指定优先级渲染任务
scheduleCallback$1(schedulerPriorityLevel, performConcurrentWorkOnRoot.bind(null, root));
}
// 作为微任务中,处理掉Scheduler中的高优任务
scheduleMicrotask(function () {
flushSyncCallbacks();
});
}
- 如果第3步中,lane为同步优先级且没有执行上下文且非并发模式,就会走到
flushSyncCallbacks
方法,该方法就是将Scheduler中高优任务执行掉。这个任务就包括我们可能在刚刚调度的渲染任务。
源码看完了,我们接下来调试下代码,再结合代码,找一下3个问题的答案:
-
Legacy模式下,更新的lane值始终是
SyncLane
。并发模式下,根据更新类型获取lane。 -
ensureRootIsScheduled
方法中,如果前面调度了一次更新,后面触发的更新优先级一样,那就直接return掉。 -
如果更新没有被return掉的话,Legacy模式下,就会去Scheduler那里调度一次高优渲染任务。并发模式下,就会去调度一次指定优先级的渲染任务。
-
scheduleUpdateOnFiber
方法里,如果当前没有执行上下文,则高优任务会立刻执行。如果有上下文,则高优任务会作为微任务在同步代码结束后异步执行。 -
事件处理器里面触发的更新,在执行时是有执行上下文的。但是Promise里面触发的更新没有执行上下文。(这是因为执行上下文是在事件处理器callback回调前设置,在结束后置空。所以异步的Promise里执行上下文就是空的)。
-
这就解释了问题1的表现,由于有上下文,并且都是同步优先级。所以后面的setState的更新被return掉了,渲染任务会在微任务里执行。实现了批处理。
-
问题2,由于没有上下文,但是是同步优先级,所以调用一次
setState
,便立即完成一次渲染。再次调用setState
时,又立即完成一次渲染。没有实现批处理。 -
问题3,并发模式下不会立即执行渲染任务,最快也要等到微任务。所以不管在哪里的多次更新,因为有相同优先级,最后走到return。也就是自动批处理特性。
到这里,我们就非常清楚的了解了批处理的实现原理,以及18版本前后的差异原因。
再来一个复杂的例子,尝试分析下这个情况:
function App() {
const [count, setCount] = useState(0);
console.log("App render");
function plus() {
setCount(count + 1);
Promise.resolve().then(() => {
setCount(count + 2);
});
}
return (
<div>
<p>{count}</p>
{/* 点击会render两次,滑入却只render一次 */}
<span onClick={plus} onMouseEnter={plus}>
滑入或点击
</span>
</div>
);
}
示例中,在onClick
和onMouseEnter
回调中都执行了plus()
方法,但是结果却完全不一样。有童鞋知道是为什么吗?
直接公布答案:
onMouseEnter
回调中触发的更新为InputContinuousLane
,而onClick
回调中触发的更新为SyncLane
。第一次setCount和第二次setCount的lane相同(只是一个是从预设值获取,一个是通过window.event获取)。
由于mouseEnter事件是普通优先级,所以调用第一次setCount后,会调度一次渲染任务,后续宏任务中执行。微任务中的第二次setCount,优先级相同,所以return掉了。最终仅渲染一次。
由于click事件是同步优先级,所以第一次setCount后会在微任务完成一次渲染任务。第二次setCount也是在微任务中,它排在渲染任务后面。所以会先完成渲染,再执行第二次setCount,并再次完成一次渲染。最终渲染两次。
思考题:如果将
plus()
中的Promise换成setTimeout。两个事件分别会渲染几次?(都渲染两次)。
退出批处理
我们上面分析到问题2的情况,会在调用setState
后立即完成一次渲染。是不是说明在setState
后获取this.state
能取到最新的状态呢?
// React17
handleClick = () => {
setTimeout(() => {
this.setState(({ count }) => ({ count: count + 1 }));
console.log(this.state); // { count: 1, flag: false }
this.setState(({ flag }) => ({ flag: !flag }));
});
};
答案是肯定的,由于是同步执行的渲染,所以this.state也在更新完成后变更了。所以取到的就是最新的state。这是类组件的表现,那函数组件下取到的state会是最新的吗?(当然不可能啊!因为函数组件中的变量是快照啊,也即临时变量,可不管你渲染完成了还是没完成)。
React18并发模式下由于都是批处理,所以同样的代码,在这里的console,就会打印出不一样的结果。即还是原来的state({ count: 0, flag: false }
)。这也是个问题哈,如果我从React17升级到React18,这里就出现了表现不一致了。
所以React提供了一个新的api:ReactDOM.flushSync
,这个方法可以让某个状态更新立即完成渲染。所以代码变为:
// React18
handleClick = () => {
setTimeout(() => {
ReactDOM.flushSync(() => {
this.setState(({ count }) => ({ count: count + 1 }));
});
console.log(this.state); // { count: 1, flag: false }
this.setState(({ flag }) => ({ flag: !flag }));
});
};