赛内卡说,折磨我们的,不是事实,而是恐惧
大家好,我是柒八九。
前言
就在前几天,我们讲了两篇关于React 18性能优化
和React Server Componment
的文章介绍。其中大部分篇幅,都是基于RSC
的.
而,今天我们来讲点不一样的东西。React 并发原理
。
又很凑巧,最近在做一个需求,有一些操作也是比较耗时和影响页面响应,您猜怎么着,只从有了新useTransiton
高钙片啊..一次吃一片..腰不疼啦,腿不痛啦..上六楼啊也有劲勒..我们瞧准啦...新useTransiton
高钙片!!!
你能所学到的知识点
- 前置知识点
- 丝滑般用户体验
- 在没有使用startTransition时,浏览器为什么会出现卡顿
- startTransition如何工作的
- 可视化并发渲染过程
- 耗时任务应该分割成组件,以便过渡正常工作
好了,天不早了,干点正事哇。
1. 前置知识点
前置知识点,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略
什么是 useTransition?
useTransition
是一个 React Hook,允许你在不阻塞用户界面的情况下更新状态。
使用 useTransition
首先,确保你的项目已经升级到 React 18
或更高版本。
并且,在你的组件的顶层调用useTransition
,以将某些状态更新标记为过渡。
import { useTransition } from 'react'; function Container() { const [isPending, startTransition] = useTransition(); // ... }
参数
useTransition
不接受任何参数。
返回值
useTransition
返回一个包含两个项的数组:
isPending
标志,用于告诉你是否有待处理的过渡。startTransition
函数,允许你将状态更新标记为过渡。
Run-to-completion VS Preemptive Multitasking
Run-to-completion
运行至完成(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
抢占式多任务处理(Preemptive Multitasking
)是一种多任务处理模型,其中操作系统具有能力中断当前正在执行的任务,并在需要时将控制权转移到其他任务。这种模型允许操作系统管理多个任务并有效地共享 CPU 时间,以实现更高的系统并发性和响应性。
特点
- 任务调度: 抢占式多任务处理依赖于任务调度器(Task Scheduler),它负责管理各个任务的执行。任务调度器按照一定的策略,如优先级、时间片轮转等,来决定哪个任务应该获得 CPU 时间。
- 中断机制: 抢占式多任务处理的核心是中断机制。当操作系统决定切换到另一个任务时,它会发送一个中断信号,将当前任务的执行状态保存起来,然后将控制权切换到另一个任务。这种切换是无缝的,用户通常不会察觉到。
- 优先级: 抢占式多任务处理支持任务的优先级,高优先级任务可以在低优先级任务之前获得执行时间。
- 并行性: 由于任务可以在任何时刻被中断和切换,多个任务可以并行执行,以提高系统的性能和响应速度。
常用的支持抢占式多任务处理的编程语言:
- C/C++: C 和 C++ 是支持抢占式多任务处理的流行编程语言。通过使用线程库(如POSIX线程库),开发人员可以创建和管理多个线程,每个线程代表一个任务,操作系统会在不同线程之间进行抢占式调度。
- Java: Java 提供了多线程支持,开发人员可以使用 Java 的
Thread
类来创建多个线程,而 Java 虚拟机(JVM)负责抢占式任务调度。 - Rust: Rust 是一门系统级编程语言,具有强大的并发和线程支持,可以用于创建高性能的多任务应用程序。
抢占式多任务处理对于需要实现高度并发、响应速度要求高的应用程序非常有用,它允许操作系统有效地管理和调度任务,确保任务能够及时响应外部事件和请求。
Web Workers 简介
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.postMessage(data)
来向 Worker 发送消息。 - 在 Worker 中,使用
self.postMessage(data)
来向主线程发送消息。
- 我们还可以在主线程和 Worker 中监听消息事件,以便处理接收到的消息。
主线程中的监听方式:
worker.addEventListener('message', (event) => { // 处理来自 Worker 的消息 const data = event.data; });
- Worker 中的监听方式:
self.addEventListener('message', (event) => { // 处理来自主线程的消息 const data = event.data; });
- 限制和注意事项:
Web Workers
不能访问DOM
,因为它们在独立的上下文中运行。- 由于数据传递是通过消息进行的,因此需要序列化和反序列化数据,这可能会导致性能开销。
Shared Workers
可能会引入竞态条件和同步问题,因此需要小心处理共享状态。
更过更详细的内容,翻看我们之前写的关于
MessageChannel的简览
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
的消息,并在收到消息时打印出来。 - 我们创建了一个新的 Web Worker,并将上述代码传递给它。然后,我们使用
worker.postMessage
向Web Worker
发送消息。这里需要注意第二个参数。
最终,你会在浏览器的控制台中看到类似以下内容的输出:
在Web Worker中接收到信息: 来自主线程的问候! 主线程中接收到的消息: 来自Web Worker的问候
这证明了通过 MessageChannel
实现了主线程和 Web Worker 之间的双向通信。
好了,天不早了,干点正事哇。
2. 丝滑般用户体验
以下是该文章将基于的CodeSandbox应用程序链接。这部分代码是从React
官网的useTransition文档的变种。
这里存在三个标签页,About/Posts (slow)/Contact
这不就是典型的公司官网介绍页面。我们通过点击对应的Button
进行内容的切换。(setTab(nextTab)
)。
App.js
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 />} </> ); }
PostTab.js: 在渲染时间方面较慢
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>; });
SlowPost.js:真正耗时的组件
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
的魅力所在。接下来,我们将其抽丝剥茧。看看它到底用了何种魔法。