React 18 如何提升应用性能
我们趋行在人生这个亘古的旅途,在坎坷中奔跑,在挫折里涅槃,忧愁缠满全身,痛苦飘洒一地。 -- 《百年孤独》
大家好,我是柒八九。
前言
最近,无意中看到一篇关于React 18
的文章,翻看之后,发现很多东西都是React
官网没有细讲的,并且发现有些点也是在实际开发中可以实践和应用的.
同时,配合我们之前讲的关于浏览器性能优化
的文章,会对React
的应用有一个更深的了解.所以就有了这篇文章.
这里做一个内容披露
,因为下文中有很多关于React Server Components
的知识细节,我们只是会做简短的说明,后面我们会有一篇单独针对它的文章.(已经在快马加鞭的再写了,不信你看)
你能所学到的知识点
- 前置知识点
- 传统React渲染模式
- 过渡
- React Server Components
- Suspence
好了,天不早了,干点正事哇。
1. 前置知识点
前置知识点,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略
并发编程 VS 并行编程
我们在Rust并发中对这两个概念有过介绍,所以直接拿来主义了.
并发编程(Concurrent programming
)和并行编程(Parallel Programming
)都是指在计算机程序中同时执行多个任务或操作的编程方式,但它们在实现方式和目标上存在一些异同点。
并发编程
并发编程
指的是在一个程序中同时进行多个任务,这些任务可以是独立的,相互之间没有直接的依赖关系。
在并发编程中,这些任务通常是通过交替执行、时间片轮转或事件驱动的方式来实现并行执行的假象。
并发编程的目标是提高程序的效率、响应性和资源利用率。
并行编程
并行编程
是指在硬件级别上同时执行多个任务,利用计算机系统中的多个处理单元(例如多核处理器)或多台计算机来同时处理多个任务。
在并行编程中,任务之间可以有依赖关系,需要进行任务的分割和协调。
并行编程的目标是实现更高的计算性能和吞吐量。
并发编程
,代表程序的不同部分相互独立的执行,而并行编程
代表程序不同部分于同时执行。
主线程和长任务
当我们在浏览器中运行 JavaScript
时,JavaScript
引擎在一个单线程环境中执行代码,这通常被称为主线程。
主线程除了执行
JavaScript
代码外,还负责处理其他任务,包括处理用户交互(如点击和键入)、处理网络事件、定时器、更新动画以及管理浏览器的回流(reflow
)和重绘(repaint
)等。
当一个任务正在被处理时,所有其他任务必须等待。虽然浏览器可以顺利执行小型任务以提供流畅的用户体验,但长时间的任务可能会带来问题,因为它们会阻塞其他任务的处理。
任何执行时间超过 50 毫秒的任务被视为长任务。
这个 50 毫秒
的基准是基于设备必须每 16 毫秒
(60 帧每秒)创建一个新帧以保持流畅的视觉体验的事实。然而,设备还必须执行其他任务,比如响应用户输入和执行 JavaScript
。
这个 50 毫秒
的基准允许设备将资源分配给渲染帧和执行其他任务,并为设备提供额外的约 33.33 毫秒
的时间来执行其他任务,同时保持流畅的视觉体验。
关于为何以50 毫秒
为基准,我们在之前的浏览器之性能指标-TBT中介绍了 RAIL 性能模型
. 这里就不再过多介绍.
为了保持最佳性能,重要的是要尽量减少长任务的数量。为了衡量我们网站的性能,有两个指标可以衡量长任务对应用程序性能的影响:总阻塞时间(Total Blocking Time,简称 TBT)和与下一次绘制的交互(Interaction to Next Paint,简称 INP)。
TBT
是一个重要的指标,它衡量了从首次内容绘制(First Contentful Paint,简称 FCP)到可交互时间(Time to Interactive,简称 TTI)之间的时间。TBT
是长于 50 毫秒的任务执行所花费的时间之和。
如上图所示,TBT
是 45 毫秒
,因为在 TTI
之前有两个任务的执行时间超过了 50 毫秒的阈值,分别超出了 30 毫秒
和 15 毫秒
。
总阻塞时间是这些数值的累加:
30 毫
秒 +15 毫秒
=45 毫秒
INP
是一个新的Core Web Vitals
指标,它衡量了从用户首次
与页面进行交互(例如点击按钮)到该交互在屏幕上可见的时间;即下一次绘制的时间。这个指标对于包含许多用户交互的页面尤为重要。它通过累积用户在当前访问期间的所有 INP
测量值,并返回最差的分数来进行衡量。
INP
为 250 毫秒,因为这是最高的测量
可见延迟。
在之前的文章中,我们介绍了很多关于浏览器性能指标.如有兴趣,可以自行获取
React_Fiber
既然聊到了React 18
,那肯定绕不过去,React Fiber
的东西,而针对Fiber
的介绍,我们之前就有对应的文章.
在这两篇文章中,会出现React 元素(虚拟DOM)
/Fiber 节点
/副作用
/渲染算法
/调和机制
. 由于这些概念都很杂很多,如果想单独了解,请自行查找,这里不做解释了.
客户端渲染(CSR) 和服务端渲染(SSR)
CSR
页面托管服务器只需要对页面的访问请求响应一个如下的空页面
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<!-- metas -->
<title></title>
<link rel="shortcut icon" href="xxx.png" />
<link rel="stylesheet" href="xxx.css" />
</head>
<body>
<div id="root"><!-- page content --></div>
<script src="xxx/filterXss.min.js"></script>
<script src="xxx/x.chunk.js"></script>
<script src="xxx/main.chunk.js"></script>
</body>
</html>
页面中留出一个用于填充渲染内容的视图节点 (div#root
),并插入指向项目编译压缩后的
JS Bundle
文件的script
节点- 指向
CSS
文件的link.stylesheet
节点等。
浏览器接收到这样的文档响应之后,会根据文档内的链接加载脚本与样式资源,并完成以下几方面主要工作:
- 执行脚本
- 进行网络访问以获取在线数据
- 使用 DOM API 更新页面结构
- 绑定交互事件
- 注入样式
其中,执行脚本就需要安装每个前端框架的内置方法,将JS代码生成对应的Virtual DOM
,然后在通过浏览器内置API将其转换为DOM
, 然后才会进行事件的绑定。
SSR
2. 传统React渲染模式
在
React
中,视觉更新分为两个阶段:渲染阶段和提交阶段。
渲染阶段是一个纯计算阶段,其中 React元素
与现有的 DOM
进行对比(也就是调和
)。这个阶段涉及创建一个新的 React 元素树,也称为虚拟 DOM
,它实质上是实际 DOM 的轻量级内存表示。
在渲染阶段,
React
计算当前 DOM
与新的 React 组件树
之间的差异,并准备必要的更新。
在渲染阶段之后是提交阶段。在这个阶段,React
将在渲染阶段计算得到的更新应用到实际 DOM 上。这涉及创建
、更新
和删除 DOM 节点
,以反映新的 React 组件树
。
在传统的同步渲染中,
React
对组件树中的所有元素赋予相同的优先级。
当组件树被渲染时,无论是在初始渲染
还是状态更新
时,React
会在一个不可中断的单一任务中渲染整个树,之后将其提交到 DOM
中,以在屏幕上更新组件的可视化效果。
同步渲染
是一种all-or-nothing
的操作,确保开始渲染的组件将始终完成渲染。根据组件的复杂性,渲染阶段可能需要一段时间才能完成。在此期间,主线程被阻塞,意味着用户在尝试与应用程序交互时会遇到无响应的用户界面,直到 React
完成渲染并将结果提交到 DOM
中。
假设存在这样的场景。有一个文本输入框
和一个包含大量城市的列表
,列表根据文本输入框当前的值进行过滤。在同步渲染中,React
会在每次键入时重新渲染 CitiesList
组件。这是一种非常昂贵的计算,因为列表包含成千上万个城市,因此在键入和在文本输入框中看到反映的过程中存在明显的视觉反馈延迟。
index.js
import { StrictMode } from "react";
import ReactDOM from "react-dom";
import App from "./App";
const rootElement = document.getElementById("root");
ReactDOM.render(<StrictMode><App /></StrictMode>, rootElement);
App.js
import React, { useState } from "react";
import CityList from "./CityList";
export default function SearchCities() {
const [text, setText] = useState("太原");
return (
<main>
<input type="text" onChange={(e) => setText(e.target.value) } />
<CityList searchQuery={text} />
</main>
);
};
CityList.js
import cities from "cities-list";
import React, { useEffect, useState } from "react";
const citiesList = Object.keys(cities);
const CityList = React.memo(({ searchQuery }) => {
const [filteredCities, setCities] = useState([]);
useEffect(() => {
if (!searchQuery) return;
setCities(() =>
citiesList.filter((x) =>
x.toLowerCase().startsWith(searchQuery.toLowerCase())
)
);
}, [searchQuery]);
return (
<ul>
{filteredCities.map((city) => (
<li key={city}>
{city}
</li>
))}
</ul>
)
});
export default CityList;
如果我们使用像
MacBook
这样的高端设备,我们可能希望将CPU
性能降低 4 倍,以模拟低端设备的情况。我们可以在开发者工具
中找到这个设置,路径是Devtools
>Performance
> ⚙️ >CPU
。
当我们查看性能选项卡时,可以看到每次输入都会发生长时间的任务,这是我们不能容忍的。
被标记为
红色角标
的任务被认为是长任务
。请注意总阻塞时间为4425.40毫秒
。
在这种情况下,React
开发者通常会使用像 debounce
这样的第三方库来延迟渲染
,但并没有内置的解决方案。
React 18
引入了一个新的并发渲染器,它在后台运行。这个渲染器为我们提供了一些方法来标记某些渲染为非紧急渲染。
当渲染低优先级组件(标记为
红色
)时,React
会让出主线程,以便检查是否有更重要的任务需要处理。
在这种情况下,React
将每隔 5 毫秒让出主线程,以查看是否有更重要的任务需要处理,比如用户输入,甚至是渲染其他 React
组件的状态更新,这些任务在当前时刻对用户体验更重要。通过持续地让出主线程,React 能够使这些渲染成为非阻塞的,并优先处理更重要的任务。这样可以改善用户体验并提高应用程序的性能。
与每次渲染一个单一的不可中断任务不同,新的并发渲染器在渲染低优先级组件时,在每个 5 毫秒的间隔内将控制权交还给主线程。
此外,并发渲染器能够在后台“并发”地渲染多个版本的组件树,而不立即提交结果。
与同步渲染是一种all-or-nothing
的计算方式不同, 并发渲染器允许 React
暂停和恢复渲染一个或多个组件树,以实现最优化的用户体验。这样的方式可以提高应用程序的性能,并确保用户界面的流畅和响应性。
当某个重要任务出现时,React
可以中断当前的渲染,转而处理该任务,然后在合适的时候继续渲染,避免了阻塞主线程和UI无响应的情况,从而提升了整体的渲染效率。
React
根据用户交互暂停当前的渲染,强制它优先渲染另一个更新。
借助并发特性,React
可以根据外部事件(如用户交互)暂停
和恢复
组件的渲染。当用户开始与 ComponentTwo
进行交互时,React
暂停当前的渲染,优先渲染 ComponentTwo
,然后再继续渲染 ComponentOne
。
3. 过渡
我们可以通过使用 useTransition
钩子提供的 startTransition
函数将更新标记为非紧急。这是一个强大的新功能,允许我们将某些状态更新标记为过渡(transitions)
,表示它们可能会导致视觉变化,如果它们同步渲染,可能会影响用户体验。
通过将状态更新包装在
startTransition
中,我们可以告诉React
我们可以推迟或中断渲染,以优先处理更重要的任务,以保持当前的用户界面的交互性。
这样的做法可以提高应用程序的性能,并确保用户界面的流畅和响应性。
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
// 紧急更新
urgentUpdate();
+ startTransition(() => {
+ nonUrgentUpdate() // 非紧急更新
+ })
}}
>...</button>
)
}
当过渡开始时, 并发渲染器会在后台准备新的组件树。一旦渲染完成,它会将结果保存在内存中,直到 React
调度程序能够高效地更新 DOM
来反映新的状态。
这个时机可能是当浏览器处于空闲状态,并且没有更高优先级的任务(比如用户交互)在等待执行时。
在这个时机,React
将会将新的渲染结果提交到 DOM
,更新用户界面,以确保更新的呈现是流畅的,并避免对用户体验产生不良影响。
使用过渡功能对于 CitiesList
示例非常适合。不必在每次输入时直接更新
传递给 searchQuery
参数的值,这样会导致每次键入都触发同步渲染调用。相反,我们可以将状态分成两个值,并在 searchQuery
的状态更新中使用 startTransition
。
这告诉 React
,状态更新可能会导致对用户造成视觉上的干扰,因此 React
应该尽力保持当前用户界面的交互性,同时在后台准备新的状态,而不立即提交更新。通过这样的方式,React
可以更加智能地管理渲染优先级
,优化用户体验,确保用户界面的流畅和响应性。
import React, { useState, useTransition } from "react";
import CityList from "./CityList";
export default function SearchCities() {
const [text, setText] = useState("太原");
const [searchQuery, setSearchQuery] = useState(text);
+ const [isPending, startTransition] = useTransition();
return (
<main>
<input
type="text"
value={text}
onChange={(e) => {
setText(e.target.value)
+ // 减低该操作的,渲染优先级
startTransition(() => {
setSearchQuery(e.target.value)
})
}} />
<CityList searchQuery={searchQuery} />
</main>
);
};
现在,当我们在输入框中输入时,用户输入保持流畅
,在按键之间没有任何视觉延迟出现。这是因为文本状态仍然同步更新
,输入框使用该状态作为其值。
在后台,React
在每次输入时开始渲染新的组件树。但是,与同步任务的all-or-nothing
不同,React
开始在内存中准备新版本的组件树,同时当前用户界面(显示“旧”状态)仍然对进一步的用户输入保持响应。
查看性能选项卡,将状态更新包装在 startTransition
中显著减少了长时间任务的数量和总阻塞时间,相比没有使用过渡的实现的性能图表。这表明使用过渡功能对于优化应用程序的性能和用户体验是非常有效的
性能选项卡显示长任务数量和总阻塞时间明显减少了。
过渡(transitions)
是 React
渲染模型中的一个基本变革,使 React
能够同时渲染多个版本的用户界面,并在不同任务之间管理优先级。这使得用户体验更加流畅和响应,尤其在处理高频更新
或 CPU 密集
的渲染任务时。过渡功能的引入为 React
应用程序的性能和交互性带来了显著的提升。
4. React Server Components
React Server Components
(简称RSC
) 是 React 18
中的实验性功能,但是,有些框架已经决定适配该功能.
传统上,React
提供了几种主要的渲染方式。
客户端渲染CSR
完全在客户端渲染所有内容
服务端渲染SSR
在服务器上将组件树渲染为 HTML
,并将这个静态 HTML 与 JavaScript 捆绑包一起发送到客户端
,用于在客户端进行组件的挂载
这两种方法都依赖于一个事实,即同步的
React
渲染器需要使用已经传递的 JavaScript 捆绑包在客户端重新构建组件树,尽管这个组件树在服务器上已经可用。
在CSR
和SSR
中,都需要通过 JavaScript 捆绑包在客户端重建组件树。
- 在
CSR
中,整个渲染过程发生在客户端的浏览器中,JavaScript
捆绑包负责生成组件树和渲染用户界面。 - 在
SSR
中,服务器预先将组件树渲染为 HTML 并将其与JavaScript
捆绑包一起发送到客户端,然后客户端接管渲染过程并挂载组件,使其成为可交互。
在这两种情况下,组件树都需要在客户端重新构建,尽管在服务器上已经有一个可用的组件树。这可能导致加载时间增加,并潜在地影响性能和用户体验。
RSC
允许 React
将实际序列化的组件树发送给客户端。客户端的 React
渲染器理解这种格式,并使用它来高效地重构 React
组件树,而无需发送 HTML 文件或 JavaScript 捆绑包。
我们可以通过将 react-server-dom-webpack/server
的 renderToPipeableStream
方法与 react-dom/client
的 createRoot
方法结合使用来采用这种新的渲染模式。
react-server-dom-webpack/server
的renderToPipeableStream
方法用于在服务器端将组件树序列化为可流式传输的格式,然后将其发送给客户端。react-dom/client
的createRoot
方法用于在客户端接收并高效地重构从服务器端传输的组件树,从而完成渲染。
server/index.cjs
const express = require('express');
const {renderToPipeableStream} = require('react-server-dom-webpack/server');
const ReactApp = require('../src/App').default;
const PORT = process.env.PORT || 4000;
const app = express();
app.listen(PORT, () => {
console.log(`Listening at ${PORT}...`);
});
app.get('/rsc', async function(req, res) {
const {pipe} = renderToPipeableStream(
React.createElement(ReactApp),
);
return pipe(res);
});
src/index.js
"use client";
import { createRoot } from 'react-dom/client';
import { createFromFetch } from 'react-server-dom-webpack/client';
export function Index() {
...
return createFromFetch(fetch('/rsc'));
}
const root = createRoot(document.getElementById('root'));
root.render(<Index />);
默认情况下,React
不会对 RSC
进行挂载。这些组件不应该使用任何客户端属性,比如访问 window
对象,或使用像 useState
或 useEffect
这样的钩子。
要将一个组件及其导入添加到 JavaScript 捆绑包中,并将其发送到客户端,从而使其具有交互性,可以在文件的顶部使用 use client
捆绑器指令。这会告诉捆绑器将此组件及其导入添加到客户端捆绑包,并告诉 React
在客户端进行挂载以增加交互性。这样的组件被称为客户端组件
(Client Components)。
注意:不同的框架实现可能会有所不同。例如,
Next.js
会在服务器上预渲染客户端组件为 HTML,类似于传统的 SSR 方法。然而,默认情况下,客户端组件的渲染方式类似于 CSR 方法。
确实,当使用客户端组件
时,优化捆绑包大小是开发者的责任。开发者可以通过以下方式实现优化:
- 确保只有交互组件的最终子节点定义了
use client
指令。这可能需要对一些组件进行解耦。 - 通过
props
传递组件树,而不是直接导入它们。这使得React
可以将子组件渲染为RSC
,而无需将它们添加到客户端捆绑包中。这样可以减少客户端捆绑包的大小。
5. Suspence
另一个重要的新并发功能是 Suspense
。虽然 Suspense
并不是完全新的,因为它在 React 16
中用于 React.lazy
的代码拆分,但在 React 18
中引入了新的功能,将 Suspense 扩展到数据获取领域。
使用
Suspense
,我们可以延迟组件的渲染,直到满足特定条件,比如从远程源加载数据。同时,我们可以渲染一个占位组件
,表示该组件仍在加载中。
通过声明式地定义加载状态,我们减少了对条件渲染逻辑的需求。将 Suspense
与 RSC
结合使用,我们可以直接访问服务器端的数据源,而无需额外的 API 端点,比如数据库或文件系统。
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}
使用
RSC
与Suspense
的结合可以无缝地工作,这允许我们在组件加载过程中定义加载状态。
Suspense
的真正威力在于它与 React
的并发特性深度整合。当一个组件被暂停(例如因为它仍在等待数据加载),React
并不会无所作为,直到组件接收到数据为止。相反,它会暂停被挂起组件的渲染,并将重点转向其他任务。
这种行为使得 React
能够更加智能地管理任务优先级,优化应用程序的性能和用户体验。当一个组件暂停时,React
会继续处理其他重要任务,如用户交互或渲染其他已准备好的组件。一旦挂起的组件获取到所需的数据,React
就会恢复其渲染,保证用户界面的流畅和响应。这种能力使得 Suspense
与并发特性的结合能够实现更高效的数据加载和渲染过程,提升应用程序的性能和用户体验。
在此期间,我们可以告诉 React
渲染一个备用的用户界面,以指示该组件仍在加载中。一旦等待的数据可用,React
就可以无缝地以中断的方式恢复先前被暂停的组件渲染。
React
还可以根据用户交互重新设置组件的优先级。例如,当用户与一个当前未被渲染的挂起组件进行交互时,React
会暂停正在进行的渲染,并将用户正在交互的组件设为优先级。
通过这种方式,React
能够更加智能地管理任务的优先级,根据用户交互动态地调整组件的渲染优先级,从而提供更好的用户体验。Suspense
与并发特性的结合为 React
提供了强大的渲染控制能力,使得应用程序的渲染过程更加灵活高效,同时保证了用户界面的流畅性和响应性。
一旦准备好,React
将其提交到 DOM
,并恢复先前的渲染。这确保了用户交互的优先级,并使用户界面保持响应,并随着用户输入实时更新。
Suspense
与 RSC
的流式格式的结合允许高优先级的更新在准备好后立即发送到客户端,而无需等待较低优先级的渲染任务完成。这使得客户端能够更早地开始处理数据,并通过逐步以非阻塞的方式展示内容,提供更流畅的用户体验。
这种可中断的渲染机制与 Suspense
处理异步操作的能力相结合,为用户提供了更加流畅和以用户为中心的体验,特别适用于具有大量数据获取需求的复杂应用程序。
通过这些并发特性,React
能够更加智能地管理任务的优先级,实现更高效的渲染和数据处理过程,为用户提供更好的交互体验,使得应用程序在处理异步操作时更加平滑和高效。
6. 数据获取
除了渲染更新外,React 18
还引入了一种新的 API 来高效地获取数据并对结果进行记忆。
React 18
现在有一个cache
函数,它可以缓存函数调用的结果。如果在同一次渲染过程中使用相同的参数再次调用相同的函数,它将使用记忆化的值,而无需再次执行该函数。
import { cache } from 'react'
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})
getUser(1)
getUser(1) // 传人的参数相同,使用缓存的数据
在数据获取的 fetch
调用中,React 18
现在默认包含了类似的缓存机制,无需使用 cache
函数。这有助于减少在单个渲染过程中的网络请求次数,从而提高应用程序的性能并降低 API 成本。
export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}
fetchPost(1)
fetchPost(1) // 传人的参数相同,使用缓存的数据
这些特性在使用 RSC
时非常有用,因为它们无法访问 Context
API。cache
和 fetch
的自动缓存行为允许将单个函数从全局模块导出,并在整个应用程序中重复使用它,这样可以更加高效地处理数据获取和记忆化。这样的设计使得在 RSC
中处理数据获取变得更加简便和高效。
async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
async function BlogPostLayout() {
const post = await fetchBlogPost('123');
return '...'
}
async function BlogPostContent() {
// 请求参数和之前的一样,返回缓存后的值
const post = await fetchBlogPost('123');
return '...'
}
export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}
总结
总体而言,React 18
的最新特性在许多方面都提高了性能。
-
使用
Concurrent
,渲染过程可以被暂停、延迟或甚至放弃。这意味着即使正在进行大规模的渲染任务,用户界面仍可以立即响应用户输入。 -
Transitions
API 允许在数据获取或屏幕切换期间实现更平滑的过渡,而不会阻塞用户输入。 -
RSC
允许开发者构建在服务器和客户端上都可用的组件,将客户端应用程序的交互性与传统服务器渲染的性能相结合,而无需付出hydration
的代价。 -
扩展的
Suspense
功能通过允许应用程序的部分内容在其他需要更长时间获取数据的部分之前渲染,提高了加载性能。
这些新特性共同为 React
应用程序带来了更高效和更流畅的用户体验。
后记
分享是一种态度。
参考资料:
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。
转载自:https://juejin.cn/post/7268221660528263220