React 18 如何提高应用程序性能

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

首先,让我们稍微回顾一下长任务的基础知识及相关的性能指标。

主线程和长任务

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

主线程负责逐个处理任务

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

任何运行时间超过 50ms 的任务都被认为是“长任务”。

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

50ms 的基准允许设备在渲染帧和执行其他任务之间分配资源,并提供额外的约 33.33ms 的时间给设备执行其他任务,同时保持视觉体验的流畅性。您可以阅读这篇RAIL 模型的博客文章,了解更多关于 50ms 基准的内容。

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

总阻塞时间(TBT)是一个重要的指标,它衡量了从首次内容绘制(FCP)到可交互时间(TTI)之间的时间。TBT 是超过 50ms 的任务执行所需的时间总和,它对用户体验可能产生重大影响。

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

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

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

要理解新的 React 更新如何针对这些度量进行优化,从而改善用户体验,首先了解传统的 React 工作方式非常重要。

传统 React 渲染

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

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

在渲染阶段之后是提交阶段。在这个阶段,React 将在渲染阶段计算出的更新应用到实际的 DOM 上。这包括创建、更新和删除 DOM 节点,最终渲染出新的 React 组件树。

在传统的同步渲染中,React 会给组件树中的所有元素相同的优先级。当一个组件树被渲染时,无论是在初始渲染还是在状态更新时,React 都会继续进行渲染,直到完成整个树的渲染任务,并将结果提交到 DOM 中,以实现屏幕上组件的视觉更新。整个过程是不可中断的。

同步渲染是一个"全有或全无"的操作,确保开始渲染的组件将始终完成渲染。根据组件的复杂性,渲染阶段可能需要一段时间才能完成。在此期间,主线程被阻塞。也就是说,用户在 React 完成渲染并提交结果到 DOM 之前,尝试与应用程序进行交互时,会遇到无响应的用户界面。

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

当我们查看性能选项卡时,您可以发现在每次按键时都会出现长时间的任务,这是不太理想的。

有红色角标的任务被认为是"长时间任务"。请注意总阻塞时间为 4425.40ms。

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

React 18 引入了一种全新的并发渲染,它在后台运行。这个渲染为我们提供了一些方式来标记某些渲染为非紧急任务。

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

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

与每次渲染的单个不可中断任务不同,并发渲染器在低优先级组件的(重新)渲染过程中以 5ms 的间隔将控制权交还给主线程。

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

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

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

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

Transitions

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

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

JSXimport { useTransition } from "react";

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

  return (
    
  )
}

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

在 CitiesList 演示中使用 transition 将是非常理想的。与直接在每次按键时调用setCities 并导致每次按键都进行同步渲染不同,我们可以将状态更新包裹在 startTransition 中。这告诉 React 该状态更新可能会导致对用户有干扰的视觉变化,因此React 应该尽力保持当前 UI 的交互体验,在后台准备新状态而不立即提交更新。

现在当我们在输入框中输入时,用户输入保持流畅,没有按键之间的视觉延迟。这是因为文本状态仍然同步更新,并且输入框使用它作为其值。然而,CitiesList 组件将其状态更新包裹在 startTransition 中。

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

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

性能选项卡显示长时间任务的数量和总阻塞时间明显减少

Transition 是 React 渲染的一个基本改变,使得 Reac t能够并发地渲染多个版本的 UI,并管理不同任务之间的优先级。这可以实现更平滑和响应迅速的用户体验,特别是在处理高频更新或 CPU 密集的渲染任务时。

React Server Components

React Server Components 是 React 18 中的一个实验性功能,但已准备好供框架采用。在我们深入了解 Next.js 之前了解这一点非常重要。

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

这两种方法都依赖于同步的 React 渲染需要使用 JavaScript bundle 包在客户端重建组件树,即使这个组件树在服务器上已经存在。

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

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

jsx// 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();

默认情况下,React 不会对 React Server Components 进行 hydration。这些组件不应该使用任何客户端交互功能,比如访问 window 对象或使用像 useState 或 useEffect 这样的 hooks。

将组件添加到一个 JavaScript bundle 包中,以便将其发送到客户端并使其具有交互性。需要在文件顶部使用 "use client",来告诉打包工具组件及其添加到客户端 bundle 包,并告诉 React 在客户端进行 hydration 以增加交互性。这种类型的组件被称为 Client Components(客户端组件)。

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

在使用 Client Components 时,开发人员需要优化 bundle 包大小。开发人员可以通过以下方式进行优化:

Suspense

另一个重要的新并发特性是 Suspense。虽然 Suspense 在 React 16 中引入,主要用于代码拆分,并且与 React.lazy 已经存在了一段时间,但 React 18 引入了新的能力,使得 Suspense 能够处理数据获取。

使用 Suspense,我们可以延迟组件的渲染直到满足特定条件,例如从远程源加载数据。同时,我们可以渲染一个占位组件来提示该组件仍在加载中。

通过声明式地定义加载状态,我们减少了对某些条件渲染逻辑的需求。结合 React Server Components 使用 Suspense,我们可以直接访问服务器端的数据源,而无需额外的 API 接口,比如访问数据库或文件系统。

jsxasync function BlogPosts() {
  const posts = await db.posts.findAll();
  return '...';
}
 
export default function Page() {
  return (
    }>
      
    
  )
}

使用 React Server Components 和 Suspense 可以无缝地工作,这使我们能够在组件加载过程中定义加载状态。

Suspense 真正的威力来自于它与 React 并发特性的深度集成。当一个组件被暂停(例如因为它仍在等待数据加载),React 不会空闲地等待组件接收数据。相反,它暂停了被挂起组件的渲染,并将重点转向其他任务。

在此期间,我们可以告诉 React 渲染一个 fallback UI,来提示该组件仍在加载中。一旦等待的数据可用,React 可以无缝地以可中断的方式恢复先前被暂停的组件的渲染,就像我们之前使用 transition 时看到的那样。

React 还可以根据用户的交互重新设置组件的优先级。例如,当用户与当前未被渲染的挂起组件进行交互时,React 会暂停正在进行的渲染,并将优先级调整为用户正在交互的组件。

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

Suspense 与 React Server Component 的可流式格式相结合,可以将高优先级的更新尽快发送到客户端,而无需等待低优先级的渲染任务完成。这使得客户端可以更早地开始处理数据,并通过逐步以非阻塞的方式显示内容,提供了更流畅的用户体验。

这种可中断的渲染机制与 Suspense 处理异步操作的能力相结合,为复杂的应用程序提供了更加平滑和以用户为中心的体验,特别是在具有重要数据获取需求的情况下。

Data Fetching

除了渲染更新之外,React 18 还引入了一种有效地获取数据并进行结果记忆的新 API。

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

jsximport { 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.

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

jsxexport 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 Components 时非常有用,因为它们无法访问上下文 API。cache 和 fetch 的自动缓存行为允许将单个函数从全局模块导出,并在整个应用程序中重复使用它。

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

总结

总结一下,React 18 的最新功能在许多方面提升了性能。


作者:ikoofe

来源:微信公众号:KooFE前端团队

出处:https://mp.weixin.qq.com/s/kX_j59RpasjFH8y4nxaYaw

展开阅读全文

页面更新:2024-05-19

标签:应用程序   性能   优先级   主线   组件   客户端   状态   阶段   时间   用户

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2020-2024 All Rights Reserved. Powered By 71396.com 闽ICP备11008920号-4
闽公网安备35020302034903号

Top