React 18如何改善性能

React 18引入了并发特性,从根本上改变了React应用程序的呈现方式。我们将探讨这些最新特性如何影响并改善您的应用程序性能。

主线程和长任务

当我们在浏览器中运行JavaScript时,JavaScript引擎在单线程环境中执行代码,通常称为主线程。除了执行JavaScript代码外,主线程还负责处理其他任务,包括管理用户交互,如点击和按键,处理网络事件,定时器,更新动画,以及处理浏览器的回流和重绘。

当一个任务正在处理时,所有其他任务必须等待。虽然浏览器可以顺利执行小任务以提供流畅的用户体验,但长时间运行的任务可能会成为问题,因为它们可能阻塞其他任务的处理。

任何需要超过50毫秒运行的任务都被认为是“长任务”。

这个50毫秒的基准是基于设备必须每16毫秒(60fps)创建一个新帧以保持平滑的视觉体验这一事实的。然而,设备还必须执行其他任务,比如响应用户输入和执行JavaScript代码。

50毫秒的基准允许设备在渲染帧和执行其他任务之间分配资源,并提供额外的约33.33毫秒的时间,让设备在维持平滑的视觉体验的同时执行其他任务。您可以在覆盖RAIL模型的这篇博客文章中了解有关50毫秒基准的更多信息。

为了保持最佳性能,最小化长任务的数量是非常重要的。为了衡量您的网站性能,有两个度量标准可以衡量长任务对您应用程序性能的影响:总阻塞时间(Total Blocking Time,TBT)和交互到下一次绘制(Interaction to Next Paint)。

总阻塞时间(TBT)是一个重要的度量标准,衡量了首次内容绘制(FCP)和可交互时间(TTI)之间的时间。TBT是超过50毫秒执行的任务所花费的时间之和,这可能对用户体验产生显著影响。

TBT为45毫秒,因为在TTI之前有两个任务的执行时间超过了50毫秒,分别超过了50毫秒的阈值30毫秒和15毫秒。总阻塞时间是这些值的累加:30毫秒 + 15毫秒 = 45毫秒。

交互到下一次绘制(INP)是一项新的核心Web Vitals指标,衡量了用户首次与页面互动(例如点击按钮)到此交互在屏幕上可见的时间;即下一次绘制的时间。这个指标对于有许多用户交互的页面,比如电子商务网站或社交媒体平台,尤其重要。它通过累积用户当前访问期间的所有INP测量值,并返回最差的分数来进行衡量。

交互到下一次绘制为250毫秒,因为它是测得的最高可视延迟。

为了理解新的React更新如何针对这些测量进行优化,从而提高用户体验,首先了解传统的React工作方式是很重要的。

传统的React渲染

React中的可视更新分为两个阶段:渲染阶段和提交阶段。React中的渲染阶段是一个纯计算阶段,在这个阶段,React元素与(即与)现有DOM进行了对比。这个阶段涉及创建一个新的React元素树,也被称为“虚拟DOM”,它实质上是实际DOM的轻量级内存表示。

在渲染阶段,React计算当前DOM与新的React组件树之间的差异并准备必要的更新。

接下来是提交阶段。在这个阶段,React将在渲染阶段计算的更新应用到实际的DOM。这包括创建、更新和删除DOM节点,以反映新的React组件树。


在传统的同步渲染中,React会给组件树中的所有元素相同的优先级。当组件树被渲染时,无论是在初始渲染还是在状态更新时,React都会继续在单个不可中断的任务中渲染整个树,之后将其提交到DOM,以在屏幕上可视地更新组件。

同步渲染是一个“一切或者什么都没有”的操作,它保证了开始渲染的组件将始终完成。根据组件的复杂性,渲染阶段可能需要一些时间才能完成。在此期间,主线程被阻塞,这意味着用户尝试与应用程序进行交互时,UI会变得不响应,直到React完成渲染并将结果提交到DOM。

您可以在以下演示中看到这种情况。我们有一个文本输入字段和一个基于文本输入当前值进行筛选的城市列表。在同步渲染中,React会在每次按键时重新渲染CitiesList组件。由于列表包含数以万计的城市,这是一个相当昂贵的计算,因此在按键和在文本输入中看到反映之间存在明显的视觉反馈延迟。

当我们查看性能选项卡时,可以看到在每次按键时都发生了长时间运行的任务,这是不太理想的情况。

在这种情况下,React开发者通常会使用像debounce这样的第三方库来推迟渲染,但没有内建的解决方案。

React 18引入了一个新的并发渲染器,它在后台运行。这个渲染器提供了一些方法,让我们可以标记某些渲染为非紧急的。

在渲染低优先级组件(粉色)时,React会让出主线程以检查是否有更重要的任务。

在这种情况下,React每5毫秒就会让出主线程,以查看是否有更重要的任务需要处理,比如用户输入,甚至是渲染另一个React组件状态更新,在那一刻对用户体验更为重要。通过不断地让出主线程,React能够使这些渲染变为非阻塞,优先处理更重要的任务。

与每次渲染都执行一个不可中断的任务不同,并发渲染器在渲染低优先级组件时每隔5毫秒就会将控制权让回主线程。

此外,并发渲染器能够在后台“并发”地渲染多个组件树的不同版本,而不立即提交结果。

而同步渲染是一个全盘或全无的计算,而并发渲染器允许React暂停和恢复一个或多个组件树的渲染,以实现最优的用户体验。

React可以根据用户交互来暂停当前的渲染,强制其优先渲染另一个更新。

使用并发特性,React可以根据用户交互等外部事件来暂停和恢复组件的渲染。当用户开始与ComponentTwo进行交互时,React会暂停当前的渲染,优先渲染ComponentTwo,然后再继续渲染ComponentOne。我们将在悬挂(Suspense)部分更详细地讨论这个问题。

Transitions

我们可以使用useTransition hook提供的startTransition函数将更新标记为非紧急。这是一个强大的新功能,允许我们将某些状态更新标记为“过渡”,表示它们可能导致视觉变化,如果同步渲染可能会破坏用户体验。

通过将状态更新包装在startTransition中,我们可以告诉React,我们可以推迟或中断渲染,以优先处理更重要的任务,以保持当前用户界面的互动性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import { useTransition } from "react";

function Button() {
const [isPending, startTransition] = useTransition();

return (
<button
onClick={() => {
urgentUpdate();
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}

当Transition开始时,并发渲染器会在后台准备新的组件树。一旦渲染完成,它将保持结果在内存中,直到React调度程序能够高效地更新DOM以反映新的状态。这个时刻可能是在浏览器处于空闲状态且没有更高优先级任务(比如用户交互)挂起的时候。

对于CitiesList演示,使用Transition将是完美的选择。与其在每次按键时直接更新传递给searchQuery参数的值(这反过来导致每次按键都调用同步渲染),我们可以将状态分为两个值,并在startTransition中包装searchQuery的状态更新。

这告诉React,状态更新可能导致视觉变化,可能对用户造成干扰,因此React应该尝试在后台准备新状态而不立即提交更新,以保持当前UI的互动性。

现在,当我们在输入框中键入时,用户输入保持平滑,没有按键之间的视觉延迟。这是因为文本状态仍然同步更新,而输入框使用它作为其值。

在后台,React在每次按键时开始渲染新的组件树。但是,与其成为一个全盘或全无的同步任务,React开始在内存中准备组件树的新版本,而当前的UI(显示“旧”状态)仍然对进一步的用户输入保持响应。

查看性能选项卡,将状态更新包装在startTransition中显著减少了长时间运行的任务的数量和总阻塞时间,相比于没有使用过渡的实现的性能图。

Transition是React渲染模型中的一个根本性转变的一部分,使React能够同时渲染多个版本的UI,并管理不同任务之间的优先级。这使得在处理高频更新或CPU密集型渲染任务时,用户体验更加流畅和响应。

React服务器组件

React服务器组件是React 18中的一个实验性功能,但已经为框架采用做好了准备。在我们深入了解Next.js之前,了解这一点是很重要的。

传统上,React提供了几种主要的渲染应用程序的方式。我们可以在客户端上完全渲染所有内容(客户端渲染),或者我们可以在服务器上将组件树渲染为HTML,并将这个静态HTML与JavaScript捆绑一起发送到客户端,以在客户端渲染组件(服务器端渲染)。

这两种方法都依赖于这样一个事实,即同步的React渲染器需要使用已经发运的JavaScript捆绑重新构建客户端的组件树,即使这个组件树在服务器上已经可用。

React服务器组件允许React将实际的序列化组件树发送到客户端。客户端的React渲染器理解这种格式,并使用它来高效地重建React组件树,而无需发送HTML文件或JavaScript捆绑。

我们可以通过将react-server-dom-webpack/server的renderToPipeableStream方法与react-dom/client的createRoot方法结合使用,来使用这种新的渲染模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// server/index.js
import App from '../src/App.js'
app.get('/rsc', async function(req, res) {
const {pipe} = renderToPipeableStream(React.createElement(App));
return pipe(res);
});

---
// src/index.js
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不会对React服务器组件进行hydrate。这些组件不应该使用任何客户端交互,比如访问window对象或使用像useState或useEffect这样的钩子。

要将一个组件及其导入添加到发运到客户端的JavaScript捆绑中,从而使其具有交互性,可以在文件的顶部使用 “use client” 捆绑指令。这告诉捆绑器将此组件及其导入添加到客户端捆绑,并告诉React在客户端进行hydrate以添加交互性。这样的组件被称为客户端组件。

注意:不同的框架实现可能会有所不同。例如,Next.js将在服务器上对Client组件进行预渲染,类似于传统的SSR方法。然而,默认情况下,Client组件的渲染类似于CSR方法。

在使用Client组件时,优化捆绑大小取决于开发人员。开发人员可以通过以下方式进行优化:

确保只有交互式组件的最末端节点定义了 “use client” 指令。这可能需要一些组件解耦。
将组件树作为props传递而不是直接导入它们。这允许React将子组件渲染为React服务器组件,而不将它们添加到客户端捆绑。

Suspense

另一个重要的新并发特性是Suspense。虽然Suspense在React 16中已经为React.lazy的代码拆分发布,但React 18引入的新功能将悬挂扩展到了数据获取。

使用悬挂,我们可以延迟组件的渲染,直到满足特定条件,比如从远程源加载数据。与此同时,我们可以渲染一个回退组件,指示此组件仍在加载中。

通过声明性地定义加载状态,我们减少了对任何条件渲染逻辑的需求。在React服务器组件与悬挂的结合使用中,我们可以直接访问服务器端的数据源,而无需使用单独的API端点,如数据库或文件系统。

1
2
3
4
5
6
7
8
9
10
11
12
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}

export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}

使用React服务器组件与Suspense的配合非常流畅,这允许我们在组件仍在加载时定义加载状态。

Suspense的真正力量来自它与React并发特性的深度集成。例如,当一个组件被暂停,因为它仍在等待数据加载时,React并不会闲置直到组件收到数据。相反,它暂停了被悬挂组件的渲染,并将焦点转移到其他任务上。

在这段时间内,我们可以告诉React渲染一个回退UI,以表明这个组件仍在加载中。一旦等待的数据变得可用,React可以在可中断的方式中无缝地恢复先前被暂停的组件的渲染,就像我们之前在过渡中看到的那样。

React还可以根据用户的交互重新排列组件的优先级。例如,当用户与当前未渲染的悬挂组件进行交互时,React会暂停正在进行的渲染,并优先处理用户正在交互的组件。

一旦准备就绪,React就会将其提交到DOM,并恢复之前的渲染。这确保了用户交互得到优先处理,UI保持响应,并随着用户输入保持最新。

悬挂与React服务器组件的流式格式的结合,允许高优先级的更新在准备就绪时立即发送到客户端,而无需等待较低优先级的渲染任务完成。这使得客户端能够更早地开始处理数据,并通过以非阻塞的方式逐渐显示内容,提供更流畅的用户体验。

这种可中断的渲染机制与悬挂处理异步操作的能力相结合,提供了一个更为流畅和以用户为中心的体验,尤其是在具有重要数据获取需求的复杂应用程序中。

Data Fetching

除了渲染更新外,React 18还引入了一个新的API来高效地获取数据和记忆化结果。

React 18现在有一个缓存函数,它记住了包装函数调用的结果。如果在同一渲染过程中使用相同的参数调用相同的函数,它将使用记忆化的值,而无需再次执行该函数。

1
2
3
4
5
6
7
8
9
import { cache } from 'react'

export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})

getUser(1)
getUser(1) // Called within same render pass: returns memoized result.

在fetch调用中,React 18现在默认包含一个类似的缓存机制,无需使用缓存。这有助于减少单个渲染过程中的网络请求数量,提高应用程序性能并降低API成本。

1
2
3
4
5
6
7
8
export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}

fetchPost(1)
fetchPost(1) // Called within same render pass: returns memoized result.

这些功能在使用React服务器组件时非常有帮助,因为它们无法访问Context API。缓存和fetch的自动缓存行为允许从全局模块导出一个函数,并在整个应用程序中重复使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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'); // Returns memoized value
return '...'
}

export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}

总结

总的来说,React 18的最新功能在许多方面提高了性能。

  • 通过并发React,渲染过程可以被暂停并稍后恢复,甚至可以被放弃。这意味着即使有一个大型的渲染任务正在进行中,UI也可以立即响应用户输入。
  • 过渡API允许在数据获取或屏幕更改期间进行更平滑的过渡,而不会阻塞用户输入。
  • React服务器组件允许开发人员构建同时在服务器和客户端上运行的组件,将客户端应用程序的交互性与传统服务器渲染的性能结合在一起,而无需hydration的成本。
  • 扩展的Suspense功能通过允许应用程序的某些部分在可能需要较长时间来获取数据的其他部分之前进行渲染,从而提高了加载性能。