Skip to content

React18是如何提升应用性能的

· 23 min

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

首先,让我们稍微了解一下长任务以及相应的性能测量基础知识。

主线程与长任务#

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

主线程负责一一处理任务

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

任何运行时间超过50毫秒的任务都被视为“长任务”。

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

这个50毫秒的基准允许设备同时为渲染帧和执行其他任务分配资源,并为设备提供额外的大约33.33毫秒来执行其他任务,同时保持平滑的视觉体验。您可以在这篇涵盖RAIL模型的博文中详细了解50毫秒的基准。

为了保持最佳性能,重要的是最大程度地减少长任务的数量。为了衡量网站的性能,有两个指标衡量长任务对应用程序性能的影响:TBT(Total Blocking Time)和INP(Interaction to Next Paint)。

TBT(Total Blocking Time)#

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

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

INP(Interaction to Next Paint)#

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

可交互到下一次绘制为250毫秒,这是测量到的最高可见延迟。

为了理解React18如何针对这些测量值进行优化,从而改善了用户体验,重要的是要了解传统React的工作原理。

过去的React渲染机制#

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

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

紧随渲染阶段之后是提交阶段。在这个阶段,React将在渲染阶段计算得出的更新应用到实际的DOM上。这涉及创建、更新和删除DOM节点,以映射新的React组件树。

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

同步渲染是一个“全有或全无”的操作,可以保证开始渲染的组件会一直完成渲染过程。根据组件的复杂性,渲染阶段可能需要一些时间才能完成。在此期间,主线程被阻塞,意味着用户在尝试与应用程序交互时会遇到无响应的界面,直到React完成渲染并将结果提交到DOM中。

可以在以下演示中看到这一现象。我们有一个文本输入字段和一个大型城市列表,根据文本输入字段的当前值进行过滤。在同步渲染中,React会在每次按键时重新渲染CitiesList组件。由于列表包含成千上万个城市,这是一项相当昂贵的计算,因此在按键和在文本输入字段中看到存在明显的视觉反馈延迟。

如果使用的是像Macbook这样的高端设备,可能需要将CPU性能降低4倍,以模拟低端设备的情况。可以在开发者工具中找到这个设置,路径是Performance > ⚙️ > CPU。

当我们查看Performance选项卡时,你会发现每次按键都会出现长时间的任务,这是不太理想的情况。

标记为红色角的任务被认为是“长任务”。4425.40ms的总阻塞时间。

在这种情况下,React开发人员通常会使用第三方库(例如Debounce)推迟渲染,但是没有内置的解决方案。

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

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

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

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

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

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

React根据用户交互来暂停当前的渲染,这迫使它优先处理另一个更新的渲染。

通过使用并发特性,React可以根据用户交互等外部事件暂停和恢复组件的渲染。当用户开始与ComponentTwo进行交互时,React会暂停当前的渲染,优先渲染ComponentTwo,然后再恢复渲染ComponentOne

Transitions#

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

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

import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
urgentUpdate();
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}

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

CitiesList演示中使用transition是非常理想的。与其在每次按键时直接更新传递给searchQuery参数的值(从而在每次按键时触发同步渲染调用),我们可以将状态拆分为两个值,并将searchQuery的状态更新包装在startTransition中。

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

现在,当我们在输入字段中键入时,用户输入保持流畅,没有按键之间的视觉延迟。这是因为text状态仍然是同步更新的,输入字段将其用作value

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

在Performance选项卡中,将状态更新包装在startTransition中显著减少了长时间任务的数量和总阻塞时间,与没有使用转换的性能图表相比。

Performance选项卡显示,长任务的数量和总阻塞时间大大减少了。

Transitions是React渲染模型中的一个基本变革,使React能够同时渲染多个版本的UI,并在不同任务之间管理优先级。这可以实现更平滑、更响应的用户体验,尤其是在处理高频更新或CPU密集型渲染任务时。

React服务器组件(RSC)#

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

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

这两种方法都依赖于同步的React渲染器需要通过使用提供的JavaScript bundle在客户端重新构建组件树,就算这个组件树已经在服务器上可用。

RSC允许React将实际y已序列化的组件树发送到客户端。客户端的React渲染器理解这个格式,并使用它来高效地重构React组件树,而无需发送HTML文件或JavaScript bundle。

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

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不会对RSC进行补水(hydration)。这些组件不应该使用任何客户端交互,比如访问window对象或使用像useStateuseEffect这样的钩子。

要将一个组件及其导入添加到JavaScript Bundle中,从而使其具有交互性,可以在文件的顶部使用“use client”指令。这会告诉Bundler将这个 组件及其导入 添加到客户端Bundle中,并告诉React在客户端进行补水,以添加交互性。这种组件被称为Client Components(客户端组件)。

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

在使用Client Components时,优化Bundle大小取决于开发者。开发者可以通过以下方式实现:

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>
)
}

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

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

React还可以根据用户交互重新安排组件的优先级。例如,当用户与一个当前没有在渲染的suspended组件进行交互时,React会暂停正在进行的渲染,并优先考虑用户正在交互的组件。

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

SuspenseRSC的可流式格式的结合,允许高优先级的更新在就绪后立即发送到客户端,无需等待低优先级的渲染任务完成。这使得客户端可以更早地开始处理数据,并逐渐以非阻塞的方式展示内容,为用户提供更流畅的体验。

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

数据获取#

除了渲染更新,React 18还引入了一种新的API来高效地获取数据并进行记忆化处理。

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

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现在默认包含了类似的缓存机制,无需使用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) // Called within same render pass: returns memoized result.

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

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>
)
}

Conclusion#

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

使用Next.js的App Router的开发人员现在可以开始利用在文章中提到的缓存和Server Components等框架可用的功能

Reference#


> cd ..