❝赛内卡说,折磨我们的,不是事实,而是恐惧 ❞
大家好,我是「柒八九」。
就在前几天,我们讲了两篇关于React 18性能优化
和React Server Componment
的文章介绍。其中大部分篇幅,都是基于RSC
的.
而,今天我们来讲点不一样的东西。React 并发原理
。
又很凑巧,最近在做一个需求,有一些操作也是比较耗时和影响页面响应,「您猜怎么着」,只从有了新useTransiton
高钙片啊..一次吃一片..腰不疼啦,腿不痛啦..上六楼啊也有劲勒..我们瞧准啦...新useTransiton
高钙片!!!
好了,天不早了,干点正事哇。
❝「前置知识点」,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。「如果大家对这些概念熟悉,可以直接忽略」 ❞
❝
useTransition
是一个 React Hook,允许你在「不阻塞用户界面的情况下更新状态」。 ❞
首先,确保你的项目已经升级到 React 18
或更高版本。
并且,在你的组件的顶层调用useTransition
,以将某些状态更新标记为过渡。
import { useTransition } from 'react';
function Container() {
const [isPending, startTransition] = useTransition();
// ...
}
useTransition
不接受任何参数。
useTransition
返回一个包含两个项的数组:
isPending
标志,用于告诉你是否有待处理的过渡。startTransition
函数,允许你将状态更新标记为过渡。运行至完成(Run-to-completion
) 是计算机科学中的一个概念,通常用于描述在「单线程」执行任务时的行为。具体来说,它表示一个任务或操作会一直执行,直到完成,而不会被中断或被其他任务打断。
Run-to-completion
意味着一个任务或操作在开始执行后将连续执行,不会在执行过程中被中断。Run-to-completion
模型中,一个任务的执行不会被其他任务或事件所打断。「一旦开始执行,任务将一直执行,直到完成或返回结果」。Run-to-completion
有助于避免竞态条件(Race Conditions)和并发问题,因为在单线程中没有多个任务可以同时访问共享资源。像我们的老朋友JavaScript
就是一个典型的单线程编程语言,所有代码都运行在一个主线程中。JavaScript 中的事件循环(Event Loop
)遵循 Run-to-completion
模型,确保在同一时刻只有一个任务在执行。
像我们平时用不到的Ruby/Lua
也属于Run-to-completion
语音
上面的语言虽然采用 Run-to-completion
模型,但它们也「支持异步编程模式」,例如使用回调函数、Promise
、async/await
等,以在需要时引入非阻塞操作,确保响应性和性能。
「抢占式多任务处理」(Preemptive Multitasking
)是一种「多任务处理模型」,其中操作系统具有能力中断当前正在执行的任务,并在需要时将控制权转移到其他任务。这种模型允许操作系统管理多个任务并有效地共享 CPU 时间,以实现更高的「系统并发性」和响应性。
Thread
类来创建多个线程,而 Java 虚拟机(JVM)负责抢占式任务调度。抢占式多任务处理对于需要实现高度并发、响应速度要求高的应用程序非常有用,它允许操作系统有效地管理和调度任务,确保任务能够及时响应外部事件和请求。
Web Workers
是一项用于在浏览器中执行多线程 JavaScript
代码的技术,它们旨在改善 Web
应用程序的性能和响应性。Web Workers
允许我们在主线程之外创建一个或多个工作线程,这些线程可以并行运行,执行计算密集型任务而不会阻塞用户界面的响应。
「类型」: 浏览器中的 Web Workers 主要有三种类型:
Dedicated Web Worker
)通常简称为工作者线程、Web Worker
或 Worker
,是一种实用的工具,可以让脚本单独创建一个 JS 线程,以执行委托的任务。只能被创建它的页面使用Shared Web Worker
):可以被多个不同的上下文使用,包括不同的页面。任何与创建共享工作者线程的脚本同源的脚本,都可以向共享工作者线程发送消息或从中接收消息Service Worker
):主要用途是拦截、重定向和修改页面发出的请求,充当网络请求的仲裁者的角色「用途」: Web Workers 可以用于各种用途,包括但不限于:
「创建」: 创建 Web Workers 非常简单。我们可以使用以下代码创建一个 Dedicated Worker:
const worker = new Worker('worker.js');
其中 'worker.js'
是 Worker 脚本的文件路径。在 Worker
脚本中,我们可以监听事件来处理消息和执行工作。
「通信」:Web Workers
与主线程之间通过消息传递进行通信。我们可以使用以下方法在主线程和 Worker 之间发送和接收消息:
我们还可以在主线程和 Worker 中监听消息事件,以便处理接收到的消息。
主线程中的监听方式:
worker.addEventListener('message', (event) => {
// 处理来自 Worker 的消息
const data = event.data;
});
Worker 中的监听方式:
self.addEventListener('message', (event) => {
// 处理来自主线程的消息
const data = event.data;
});
worker.postMessage(data)
来向 Worker 发送消息。self.postMessage(data)
来向主线程发送消息。「限制和注意事项」:
Web Workers
不能访问 DOM
,因为它们在独立的上下文中运行。Shared Workers
可能会引入竞态条件和同步问题,因此需要小心处理共享状态。MessageChannel
是 HTML5
中的一个 API,它允许你在不同的 JavaScript
线程之间传递消息。这对于在主线程和 Web Workers
之间进行通信非常有用。
下面是一个使用 MessageChannel
用于主线程和worker
之间数据通信的的示例代码:
// 创建一个新的 MessageChannel
const channel = new MessageChannel();
// 获取消息的两个端口
const mainPort = channel.port1;
const workPort = channel.port2;
// 在主线程中监听来自workPort的消息
mainPort.onmessage = (event) => {
console.log(`主线程中接收到的消息: ${event.data}`);
};
// 在 Web Worker 中监听来自port1的消息
// 我们利用Blob 进行Web Worker的实例化处理
const workerCode = `
self.onmessage = (event) => {
const port = event.ports[0];
console.log('在Web Worker中接收到信息:', event.data.message);
port.postMessage('来自Web Worker的问候')
};
`;
// 创建一个新的 Web Worker,并将端口workPort传递给它
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob), { type: 'module' });
worker.postMessage({message:'来自主线程的问候!'},[workPort]);
这段代码做了以下事情:
MessageChannel
,它包含两个端口:mainPort
和 workPort
。mainPort.onmessage
事件监听来自 workPort
的消息,一旦有消息到达,就会触发回调函数,打印消息内容。Web Worker
中,我们利用Blob
进行Web Worker
的实例化处理,它监听来自 self.onmessage
的消息,并在收到消息时打印出来。worker.postMessage
向 Web Worker
发送消息。这里需要注意第二个参数。最终,你会在浏览器的控制台中看到类似以下内容的输出:
在Web Worker中接收到信息: 来自主线程的问候!
主线程中接收到的消息: 来自Web Worker的问候
这证明了通过 MessageChannel
实现了主线程和 Web Worker 之间的双向通信。
好了,天不早了,干点正事哇。
以下是该文章将基于的CodeSandbox应用程序链接。这部分代码是从React
官网的useTransition文档的变种。
这里存在三个标签页,About/Posts (slow)/Contact
这不就是典型的公司官网介绍页面。我们通过点击对应的Button
进行内容的切换。(setTab(nextTab)
)。
import { useState, useTransition } from "react";
import TabButton from "./TabButton.js";
import AboutTab from "./AboutTab.js";
import PostsTab from "./PostsTab.js";
import ContactTab from "./ContactTab.js";
export default function TabContainer() {
const [isPending, startTransition] = useTransition();
const [tab, setTab] = useState("about");
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
return (
<>
<TabButton
isActive={tab === "about"}
onClick={() => selectTab("about")}
>
About
</TabButton>
<TabButton
isActive={tab === "posts"}
onClick={() => selectTab("posts")}
>
Posts (slow)
</TabButton>
<TabButton
isActive={tab === "contact"}
onClick={() => selectTab("contact")}
>
Contact
</TabButton>
<hr />
{tab === "about" && <AboutTab />}
{tab === "posts" && <PostsTab />}
{tab === "contact" && <ContactTab />}
</>
);
}
const PostsTab = memo(function PostsTab() {
// 只记录一次。真正的耗时任务发生在SlowPost内部。
console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />");
let items = [];
for (let i = 0; i < 1000; i++) {
items.push(<SlowPost key={i} index={i} />);
}
return <ul className="items">{items}</ul>;
});
function SlowPost({ index }) {
console.log("rendering post " + index);
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 每项等待1毫秒不执行任何操作,以模拟耗时操作。
}
return <li className="item">Post #{index + 1}</li>;
}
PostsTab
组件充当多个 SlowPost
组件的容器,每个 SlowPost
组件需要 1 毫秒进行渲染。因此,如果有 1000 篇帖子需要渲染,并且每篇帖子对应一个 SlowPost
组件,那么 PostsTab
组件的总渲染时间将为 1 秒。在这 1 秒的时间内,浏览器在用户交互方面可能会变得「迟钝」。然而,由于在 startTransition
回调中进行处理,通常会导致明显页面卡顿的现象,此时却「销声匿迹」。
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
为了能更直观的体验这种「如德芙般丝滑的感觉」,我们可以按照下面的步骤操作一下:
About
页面上,选择Posts (slow)
选项卡。Contact
页面。如果Posts
页面显示得过快,我们可以将帖子数量从 1000(即 1 秒渲染时间)增加到更大的数量。
正如我们可以注意到的,选择Posts
页面后立即选择Contact
页面时,没有出现延迟。使用 startTransition
就是使这种流畅用户体验成为可能的关键。
为了感受 startTransition
的神奇之处,我们可以尝试注释掉 startTransition
部分,并按照上述步骤进行操作:
function selectTab(nextTab) {
// startTransition(() => {
//当nextTab ==='post'时,页面明显出现卡顿现象
setTab(nextTab);
// });
}
现在,如果需要渲染 2000
篇帖子,我们应该会注意到在点击Posts (slow)
选项卡后会出现 2 秒的冻结时间。
这就是startTransition
的魅力所在。接下来,我们将其「抽丝剥茧」。看看它到底用了何种魔法。
这是一个「来自底层Reacter的渴求真理」的发问。
想找到这个答案的关键在于理解在 React
的上下文中「渲染的真正含义」。一个组件被渲染是什么意思? - 用非常简单的话来说
❝渲染意味着调用代表 React 组件的函数 ❞
关于React
渲染机制的介绍,可以参考我们之前写的文章,这里也不再赘述。
让我们在回顾一下,刚才渲染卡顿部分的代码。
// ==========================================================
function SlowPost({ index }) {
console.log("rendering post " + index);
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 每项等待1毫秒不执行任何操作,以模拟耗时操作。
}
return <li className="item">Post #{index + 1}</li>;
}
// ==========================================================
// 省略部分代码
function PostsTab() {
const items = [];
for (let i = 0; i < 1000; i++) {
items.push(<SlowPost index={i} />)
}
}
因此,渲染 PostsTab
组件意味着执行 PostsTab()
函数。这意味着 SlowPost
函数将会被调用 1000 次,而且由于调用 SlowPost
需要 1 毫秒,总的渲染时间将会是 1 秒。
现在我们已经理解了渲染的含义,我们也得到了第一个提示:耗费时间的是渲染,而不是浏览器构建网页。或者换句话说,「耗费时间的是渲染阶段,而不是将渲染的元素提交到实际 DOM 中的动作」。
❝渲染(即在确定新的页面变更时调用的函数,这些更改最终会显示在实际 DOM 中)与提交到 DOM 之间有明显的区别。 ❞
有趣的是,「提交阶段不一定总是在渲染阶段之后发生」。例如,可以渲染一组虚拟 DOM 节点,但它们对实际 DOM 的提交可以被延迟。--这一点,我们会有一篇文章介绍相关内容
当我们使用React
的语法,来进行页面切换时,如下面的代码,在React
底层到底发生了啥?
function selectTab(nextTab) {
// startTransition(() => {
setTab(nextTab);
// });
}
我们来用另外一段伪代码
来解释上面的发生的处理逻辑。
当点击Posts (slow)
后,React
会「同步地渲染整个树」。这类似于执行以下操作:
// 处理页面切换后的页面渲染逻辑
const selectSlowPostsTab = () => {
// 这是一个耗时1分钟的函数调用
renderPostsTab();
// 该函数将在1秒后执行(也就是在上面的函数执行完成后,才会被触发执行)
commitChangesToTheRealDOM();
}
// ============================================================
const renderPostsTab = (...args) => {
for (let postIdx = 0; postIdx < 1000; postIdx++) {
renderSlowPost();
}
}
const renderSlowPost = (...args) => {
const startTime = performance.now();
while (performance.now() - startTime < 1) {
// 每项等待1毫秒不执行任何操作,以模拟耗时操作。
}
return;
}
当然,在现实中,情况要比这复杂得多。但上述伪代码应该能够突显问题所在 - 「渲染(即调用一些 JavaScript 函数)需要很多时间,因此用户会注意到延迟」。
到目前为止,我们已经理解了问题所在,而且不知何故,startTransition
函数通过包装设置状态的函数来神奇地解决了这个问题:
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
还有一些值得考虑的因素:JavaScript
的「执行模型是Run-to-completion」,这意味着一个函数在执行过程中不能被中断并在以后继续执行。这种语言特性对我们来说意味着 renderPostsTab
函数的执行,除非我们采取一些「非常规手段」,否则函数无法被停止,也就意味着即使现在有更高优先级的任务需要被执行,它也只能「干瞪眼」。
我们之前在浏览器性能指标系列中,有过介绍,如果一个任务/函数一次处理太长时间,我们可以将其分成较小的块,并通过将它们与其他需要在主线程上花费时间的任务交错进行,定期处理它们。
既然,这是一个可行的方案,并且也是一种处理「长任务」的一种有力的工具,那我们可以大胆的做一个假设,是不是startTransition
也是利用这种机制,将长任务变成短任务,然后利用其中的优化机制,适时的将主线程空出来,来处理优先级更高的任务。
通过上文分析,「将一项庞大的任务分成较小的任务是解决浏览器因渲染需要太多时间而变得不响应用户交互的良好方法」 。
重申一下我们关于startTransition
函数假设 - 将耗时的渲染任务分成块,并定期让出给浏览器的主线程,以使页面保持响应。换句话说,startTransition
将启动「并发模式」。然而要注意的是,startTransition
「并不是负责将任务分解为较小的任务」
首先,让我们测试一下上面所说的是否确实正确。为此,让我们再次打开 CodeSandbox 应用程序:
大家额外多关注一下 console.log()
调用。最重要的是 SlowPost
组件中的那个调用。
在此之前,我们有几个概念,需要知晓一下:
JavaScript
在单线程环境中运行。虽然可以利用其他附加线程(例如通过WebWorker
、ServiceWorker
),但只有一个主线程,也称为UI线程。这个线程不仅负责执行开发人员编写的JavaScript代码(例如事件监听器)等任务,还负责渲染任务、解析CSS等任务。每当执行一个函数时,整个主线程都会在执行该函数时被阻塞,因为主线程一次只能运行一个任务。这是网页可能变得无响应的原因 - 主线程正在忙于执行某些逻辑。
之前我们在介绍「浏览器性能指标时」提到过RAIL
- 在其中,我们可以看到哪些延迟在不同情况下是可以接受的,任务应该花费多少毫秒等等。
❝把控制权让给主线程意味着中断渲染过程,并让浏览器有机会执行其他任务,例如渲染、接收用户输入等。 ❞
有一些浏览器 API 允许 React
实现这一点。例如,window.setImmediate()
「此方法用于打断长时间运行的操作,并在浏览器完成其他操作(例如事件和显示更新)后立即运行回调函数」。
但是,由于它「性格有点问题,都不受各个内核的待见」,被赐予了「一丈红」的待遇。
好消息是有其他方法可以达到相同的结果,其中之一就是 MessageChannel API。
这正是 React
如何使用 MessageChannel
API 来安排在浏览器执行了一些基本任务后运行函数的方式:
// 创建一个新的 MessageChannel
const channel = new MessageChannel();
// 从 MessageChannel 中获取 port2,用于后续的通信
const port = channel.port2;
// 在 port1 上设置消息监听器,以便在消息到达时执行 performWorkUntilDeadline 函数
channel.port1.onmessage = performWorkUntilDeadline;
// 定义一个名为 schedulePerformWorkUntilDeadline 的函数
schedulePerformWorkUntilDeadline = () => {
// 向 port 发送一个空消息,触发 port1 上的消息监听器
port.postMessage(null);
};
调度是在调用 schedulePerformWorkUntilDeadline()
时进行的。
因此,通过调用 schedulePerformWorkUntilDeadline()
并在浏览器获得足够的时间接收用户交互和执行其他与浏览器相关的任务之后,将会调用 performWorkUntilDeadline()
,这是 React 相关的预定任务将被执行的地方。
在前一节中,我们已经看到会调用 schedulePerformWorkUntilDeadline()
来安排在浏览器的基本任务后进行一些工作 - 次举有助于消除浏览器卡顿现象。
❝进而我们可以进一步联想到
startTransition
会导致schedulePerformWorkUntilDeadline()
被「周期性地调用」。因此,不是所有的SlowPost
组件都应该立即被渲染。 ❞
我们如何断定这一点?
让我们在 CodeSandbox 应用程序中打开开发者工具,并放置以下日志点:
有几个值得注意的关键点:
在最左边的面板中,我们添加了一个日志,以帮助我们理解何时渲染 SlowPost
组件。(在代码中的17行)
在最右边的面板中,我们在 scheduler.development.js
文件的第 538 行添加了一个日志点
在最右边的面板中,在第 517 行,注意 performWorkUntilDeadline()
如何调用 schedulePerformWorkUntilDeadline()
,后者将通过 MessageChannel
API 安排 performWorkUntilDeadline()
的调度;以下是它的实现方式:
const channel = new MessageChannel();
const port = channel.port2;
channel.port1.onmessage = performWorkUntilDeadline;
schedulePerformWorkUntilDeadline = () => {
port.postMessage(null);
};
正如我们注意到的,这里正在进行「递归」;「这是保证 React 定期将控制权让给主线程的机制」。
最后,在最右边的面板中,调用 scheduledHostCallback
将导致(某些)预定任务被执行。
现在,是时候查看日志并观察其运行了。在 Console 面板可见的情况下,尝试点击Posts (slow)
选项卡,然后迅速点击Contact
选项卡。完成这些操作后,控制台中可能会显示类似以下的内容:
❝正如我们所看到的,
SlowPosts
组件「不会一次性全部渲染,而是分批次进行」,以便浏览器有足够的时间响应用户。 ❞
关于React
最新架构-Fiber
我们之前有文章介绍过,这里也不再赘述。
为了理解并发渲染的美妙之处,最首要的任务是要了解 React
如何渲染组件树。
React
的「同步渲染」过程大致如下:
while (workInProgress !== null) {
performUnitOfWork(workInProgress);
}
其中,workInProgress
表示当前正在处理的虚拟 DOM 节点。调用 performUnitOfWork()
可以触发渲染组件。(例如,在 workInProgress
的current
属性分配给一个函数组件时,进行组件渲染)
我们继续以 PostsTab
组件来分析:
const PostsTab = memo(function PostsTab() {
let items = [];
for (let i = 0; i < 1000; i++) {
items.push(<SlowPost key={i} index={i} />);
}
return <ul className="items">{items}</ul>;
});
function SlowPost({ index }) {
let startTime = performance.now();
while (performance.now() - startTime < 1) { }
return <li className="item">Post #{index + 1}</li>;
}
在 PostsTab
渲染后,对应的虚拟 DOM 大致如下:
❝渲染的结果是,
PostsTab()
返回了一个包含其他React
元素的数组(稍后将转换为虚拟 DOM 节点)。 ❞
之后,每个返回的 SlowPost
子组件都会一个接一个成为 workInProgress
。
所以,首先,workInProgress = PostsTabNode
,然后调用 performUnitOfWork(workInProgress)
,然后 workInProgress = SlowPost0Node
,然后调用 performUnitOfWork(workInProgress)
,然后 workInProgress = SlowPost1Node
,以此类推。
当「并发渲染」时,while
循环如下所示:
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
这里最核心的部分是!shouldYield()
- 「这是允许 React 中断渲染过程然后将控制权让给主线程的部分」。这就是 shouldYield()
实现的相关内容:
const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
// 主线程只被阻塞了很短的时间;
// 小于一个帧的时间。暂时不放权。
return false;
}
// 省略了一些代码
return true;
❝换句话说,
shouldYield()
检查React
是否已经在渲染上花费了足够的时间, ❞
performUnitOfWork()
,直到 while
循环的下一次检查,再次咨询 shouldYield()
。这就是并发渲染的本质。现在,让我们将问题中的示例可视化:
上面的图表(几乎)对应于我们在控制台中注意到的行为:
让我们回顾一下正在发生的事情:React
通过遍历组件树来渲染它。当前正在被访问(即将被渲染)的节点由 workInProgress
表示。遍历发生在 while
循环中,这意味着在继续执行工作(例如渲染)之前,它会首先检查是否应该将控制权让给主线程(由 shouldYield()
函数进行判断)。
while
循环将停止,将会安排一个任务在浏览器完成一些工作后运行,同时确保对当前 workInProgress
的引用将保留以便下次渲染时恢复。performUnitOfWork(workInProgress)
将被调用,之后 workInProgress
将被分配给下一个需要遍历的虚拟 DOM 节点。此时,我们应该对并发渲染的工作原理有了至少一点了解。但是,仍然有一些东西缺失 - startTransition
如何激活并发渲染?简短的答案是,「当调用该函数时,一些标志最终被添加到根节点上,这些标志告诉 React 可以以并发模式渲染该树」。
这是一个演示 startTransition
变得无效的例子:
const PostsTab = memo(function PostsTab() {
let items = [];
// 页面应该在此时变得无响应 4 秒钟。
for (let i = 0; i < 4000; i++) {
// 不再将任务分成较小的部分!
// items.push(<SlowPost key={i} index={i} />);
let startTime = performance.now();
while (performance.now() - startTime < 1) {
// 为了模拟极慢的代码,每个项等待 1 毫秒。
}
items.push(<li className="item">Post #{i + 1}</li>);
}
return <ul className="items">{items}</ul>;
});
上面的代码,是我们「刻意为之」的。但是它能说明虽然了设置 startTransition
但是页面也会存在卡顿现象。
function selectTab(nextTab) {
startTransition(() => {
setTab(nextTab);
});
}
点击Posts (slow)
选项卡将导致网页变得无响应,因此点击Contact
选项卡只有在 4 秒后才会生效(即 PostsTab
渲染所需的时间)。
最初的问题是多个每个都需要 1 毫秒的较小任务会同步渲染(总渲染时间为 1ms * 小任务总数
)。通过 startTransition
处理后它能够中断树遍历(因此中断了渲染过程),以便浏览器可以处理高优先级任务。现在,问题是一个单一的任务需要 4 秒。基本上,并发模式变得无效,因为一个单独的单位需要实际上太长的时间。并发模式依赖于有多个需要遍历的 workInProgress
节点。
在初始示例中,有 1000
个 workInProgress
SlowPost
组件 - 它们可以轻松分成一批批次,例如,每个批次有 5 个 SlowPost 组件,意味着这样的批次将花费 5 毫秒。完成一批后,轮到浏览器在其他任务上工作,然后再次等待另一批次,如此循环重复,直到没有其他内容需要渲染。
但是,如果一个单个任务已经超过了浏览器一帧的渲染时间,那虽然设置了startTransition
,但是也「无能为力」。如果存在这种情况,那就只能人为的将单个任务继续拆分或者利用Web Worker
进行多线程处理了。
「分享是一种态度」。
参考资料: