很久没有学习React,正好React18也出来了一段时间,根据 React 18的介绍,最新的React将带来主要3个方面的新特性:
- 自动批处理state更新
- 支持Suspense的新SSR架构
- Concurrent features
要学习,那最好带着问题出发,这次就要研究React18的第一个新特性,React是如何实现Automatic batching(批处理)的,以前对于React也没太多的研究(反正就是自己懒呗,再加上996),主要是根据一些博客和简单的debug React源码简单了解。直接看一段代码简单了解什么是React批处理吧(多次setState,一次render):
React17更新批处理
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>react是如何实现批量更新的?</title>
<script src="https://unpkg.com/babel-standalone@6.26.0/babel.js"></script>
<script src="./react@18.0.0.umd.js"></script>
<script src="./react-dom@18.0.0.umd.js"></script>
</head>
<body>
<div id="container" style="text-align: center;"></div>
<script type="text/babel">
const getDataSync = () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 1000);
});
};
class Root extends React.Component {
constructor(props) {
super(props);
this.state = {
num1: 0,
num2: 0,
num3: 0,
};
}
updateNums = () => {
getDataSync().then(() => {
debugger;
this.setState({
num1: this.state.num1 + 1,
});
debugger;
this.setState({
num2: this.state.num2 + 1,
});
debugger;
this.setState({
num3: this.state.num3 + 1,
});
});
};
render() {
console.log("------- render -----------");
const { num1, num2, num3 } = this.state;
return (
<div onClick={this.updateNums}>
{num1}
{num2}
{num3}
</div>
);
}
}
const container = document.getElementById("container");
const root = ReactDOM.createRoot(container);
root.render(<Root />);
</script>
</body>
</html>
以上代码在react之前会执行3次render^_^,在这里,之后执行一次,一步步来,先来了解经典的批处理,即事件中多次setState:
updateNums = () => {
debugger;
this.setState({
num1: this.state.num1 + 1,
});
debugger;
this.setState({
num2: this.state.num2 + 1,
});
debugger;
this.setState({
num3: this.state.num3 + 1,
});
};
<div onClick={updateNums}></div>
上面的代码中onClick事件并非真正的dom事件,经过babel转换后:
<div onClick={updateNums}></div>
React在createRoot(18中的新ReactDOM api)方法中代理了所有原生事件,在react虚拟dom转换成真实dom的时候,会在真实dom上绑当前的fiber
var listeningMarker = "_reactListening" + Math.random().toString(36).slice(2);
function listenToAllSupportedEvents(rootContainerElement) {
console.log('------ listenToAllSupportedEvents --------');
if (!rootContainerElement[listeningMarker]) {
rootContainerElement[listeningMarker] = true;
allNativeEvents.forEach(function (domEventName) {
// We handle selectionchange separately because it
// doesn't bubble and needs to be on the document.
if (domEventName !== "selectionchange") {
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement);
}
listenToNativeEvent(domEventName, true, rootContainerElement);
}
});
var ownerDocument =
rootContainerElement.nodeType === DOCUMENT_NODE
? rootContainerElement
: rootContainerElement.ownerDocument;
if (ownerDocument !== null) {
// The selectionchange event also needs deduplication
// but it is attached to the document.
if (!ownerDocument[listeningMarker]) {
ownerDocument[listeningMarker] = true;
listenToNativeEvent("selectionchange", false, ownerDocument);
}
}
}
}
从点击target中取到fiber
现在可以从触发的事件找到对应的fiber了,一切都准备好了,就开始执行batchUpdate,再找到当前节点上onClick对应的方法:
执行onClick方法,即调用setState,setState主要就是enqueue TaskQueue,并在port.postMessage创建的宏任务中消费(flushWork -> workLoop),React在这里就不会让每次setState执行port.postMessage,具体控制逻辑在方法ensureRootIsScheduled中:
// react@17.0.1
// existingCallbackNode = scheduleSyncCallback();
// Check if there's an existing task. We may be able to reuse it.
if (existingCallbackNode !== null) {
var existingCallbackPriority = root.callbackPriority;
if (existingCallbackPriority === newCallbackPriority) {
// The priority hasn't changed. We can reuse the existing task. Exit.
return;
}
// The priority changed. Cancel the existing callback. We'll schedule a new
// one below.
cancelCallback(existingCallbackNode);
}
调试发现scheduleSyncCallback返回空对象 {} ~; 所以这里就只要判断existingCallbackPriority === newCallbackPriority 就直接return。第一次执行existingCallbackNode空执行scheduleSyncCallback,后续的setState只执行了enqueue TaskQueue,这就是react的更新事务的实现。
以上就是React17的更新批处理。在事件中执行setState批处理18也差不多。
脱离React事件系统的批量更新
上述内容都是React一直存在的批量更新事务逻辑,这次更新的点在脱离React事件系统的批量更新。上面提到事件中调用setState后最终更新逻辑在下一个port.postMessage宏任务中,在最上面的例子中,setState在第一次port.postMessage之后:
updateNums = () => {
getDataSync().then(() => {
debugger;
this.setState({
num1: this.state.num1 + 1,
});
debugger;
this.setState({
num2: this.state.num2 + 1,
});
debugger;
this.setState({
num3: this.state.num3 + 1,
});
});
};
先看看react17如何处理:
// var executionContext = NoContext; // The root we're working on
scheduleUpdateOnFiber() {
// ...此处省略N行react源代码
if (lane === SyncLane) {
// ...此处省略N行react源代码
} else {
ensureRootIsScheduled(root, eventTime);
schedulePendingInteractions(root, lane);
if (executionContext === NoContext) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbackQueue();
}
}
}
// ...此处省略N行react源代码
}
这里executionContext为NoContext,就直接执行了flushSyncCallbackQueue,也就是说这里setState enqueue TaskQueue之后,直接flush掉,不再在port.postMessage后flush了。这也就是为什么18之前的版本多次render的原因,拓展思考一下,queueMicrotask也会多次render,因为这里直接同步执行了。
再看看react18的处理逻辑:
scheduleUpdateOnFiber() {
if (
(executionContext & RenderContext) !== NoLanes &&
root === workInProgressRoot
) {
// ...此处省略N行react源代码
} else {
// ...此处省略N行react源代码
ensureRootIsScheduled(root, eventTime);
if (
lane === SyncLane &&
executionContext === NoContext &&
(fiber.mode & ConcurrentMode) === NoMode && // Treat `act` as if it's inside `batchedUpdates`, even in legacy mode.
!ReactCurrentActQueue$1.isBatchingLegacy
) {
// Flush the synchronous work now, unless we're already working or inside
// a batch. This is intentionally inside scheduleUpdateOnFiber instead of
// scheduleCallbackForFiber to preserve the ability to schedule a callback
// without immediately flushing it. We only do this for user-initiated
// updates, to preserve historical behavior of legacy mode.
resetRenderTimer();
flushSyncCallbacksOnlyInLegacyMode();
}
}
}
很明显的看出来flushSyncCallbackQueue可调用判断严格了很多,反正是肯定进不去了
再细看ensureRootIsScheduled方法:
这就很明显了,fiber.mode直接默认为concurrentMode,命名上就能知道要并发渲染,第二次setState时:
if (
existingCallbackPriority === newCallbackPriority && // Special case related to `act`. If the currently scheduled task is a
// Scheduler task, rather than an `act` task, cancel it and re-scheduled
// on the `act` queue.
!(
ReactCurrentActQueue$1.current !== null &&
existingCallbackNode !== fakeActCallbackNode
)
) {
return;
}
所以react18相当于屏蔽了ensureRootIsScheduled()后flushSyncCallbackQueue的执行来处理批量更新,当然了,具体细节有很多,这里仅仅分析宏观上的代码逻辑。
写这篇文章仅为记录react18 Automatic batching 新特性的实现源码的学习(上班时间写的☠)。
新特性总结:妈妈再也不用担心我的代码会执行多次render了~