码农之家

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

本文为翻译文,原文地址:vercel.com/blog/how-re…

通过本篇文章你可以了解 Transitions、Suspense 和 React Server Components 等并发功能如何提高应用程序性能。

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

首先,让我们退一步来了解长任务的基础知识和相应的性能测量。

主线程和长任务

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

主线程负责将任务一一处理,如下图所示:

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

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

此 50 毫秒基准基于以下事实:设备必须每 16 毫秒 (60 fps) 创建一个新帧才能保持流畅的视觉体验。然而,设备还必须执行其他任务,例如响应用户输入和执行 JavaScript。

50 毫秒的基准测试允许设备将资源分配用于渲染帧和执行其他任务,并额外提供了约 33.33 毫秒的时间,让设备在保持流畅的视觉体验的同时执行其他任务。您可以阅读这篇涵盖了 RAIL 模型的文章,以获取有关于 50 毫秒基准测试的更多信息。

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

总阻塞时间(TBT)是一个重要的指标,它衡量了从首次内容呈现(FCP)到可交互时间(TTI)之间的时间。TBT 是那些执行时间超过 50 毫秒的任务所用时间的总和,这可能会对用户体验产生显著影响。

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

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

到下一次绘制的交互时间为 250 毫秒,因为这是测量到的最高视觉延迟,如下图所示:

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

传统的 React 渲染

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

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

渲染阶段之后是提交阶段。在此阶段,React 将渲染阶段计算出的更新应用于实际 DOM。这涉及新增、更新和删除 DOM 节点以 clone 克隆新的 React 组件树。

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

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

您可以在 codesandbox 中看到这种情况。我们有一个文本输入字段和一个包含大量城市的列表,这些城市基于文本输入字段的当前值进行筛选。在同步渲染中,React 会在每次按键时重新渲染 CitiesList 组件。这是一个相当昂贵的计算,因为列表包含成千上万个城市,所以在按键和在文本输入字段中看到结果渲染出来之间存在明显的视觉反应延迟,也就是卡顿的效果。

当我们查看性能选项卡时,您可以看到每次击键都会发生很长的任务,这是次优的。

下图是在同步渲染中的展示效果:

在这种情况下,React 开发人员经常会使用 debounce 等第三方库来延迟渲染,但没有内置的解决方案。

React 18 引入了一个在幕后运行的新并发 Concurrent 渲染。该模式为我们提供了一些将某些渲染标记为非紧急的方法。

当渲染低优先级组件(粉色)时,React 返回主线程以检查更重要的任务

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

并发渲染 Concurrent 不是为每个渲染执行一个不可中断的任务,而是在低优先级组件的(重新)渲染期间以 5 毫秒的间隔将控制权交还给主线程。

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

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

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

通过并发功能,React 可以根据用户交互等外部事件暂停和恢复组件的渲染。当用户开始与 ComponentTwo 进行交互时,React 会暂停当前的渲染,优先渲染 ComponentTwo,然后恢复渲染 ComponentOne。我们将在“暂停”一节中详细讨论这个问题。

Transitions 过渡

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

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

import { useTransition } from "react";

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

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

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

使用 Transitions 对于 CitiesList 演示来说是完美的。searchQuery 我们可以将状态拆分为两个值,并将 的状态 searchQuery 更新包裹在 startTransition.

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

完整代码可通过 codesandbox 进行查阅。

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

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

查看性能选项卡,startTransition 与不使用转换的实现的性能图相比,将状态更新包装在显着减少的长任务数量和总阻塞时间中。

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

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

React Server Components

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

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

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

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

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

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

这是下面所示的 CodeSandbox 演示的 transition 简化示例。

单击此处查看完整的 CodeSandbox 演示。在下一节中,我们将介绍一个更详细的示例。

默认情况下,React 不会 hydrate React 服务器组件。这些组件不应使用任何客户端交互性(例如访问窗口对象)或使用 useState 或 useEffect 等挂钩。

为了将一个组件及其导入内容添加到发送到客户端的 JavaScript 捆绑包中,从而使其具有交互性,您可以在文件顶部使用“use client”捆绑指令。这告诉捆绑器将此组件及其导入内容添加到客户端捆绑包中,并告诉 React 在客户端上进行“hydration”以添加交互性。这样的组件被称为客户端组件(Client Components)。

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

在使用客户端组件时,开发人员需要优化捆绑包的大小。开发人员可以通过以下方式做到这一点:

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

Suspense

另一个重要的新并发功能是“暂停”(Suspense)。尽管“暂停”在 React 16 中已经用于与 React.lazy 一起进行代码分割,但 React 18 引入了新的功能,将“暂停”扩展到了数据获取。

使用“暂停”(Suspense),我们可以延迟渲染组件,直到满足某些条件,比如从远程源加载数据。与此同时,我们可以渲染一个回退组件,表示该组件仍在加载中。

通过声明性地定义加载状态,我们减少了对任何条件渲染逻辑的需求。将“暂停”与 React 服务器组件结合使用,使我们能够直接访问服务器端的数据源,而无需额外的 API 端点,比如数据库或文件系统。

async function BlogPosts() {
  const posts = await db.posts.findAll();
  return "...";
}

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

使用 React Server Components 与 Suspense 无缝协作,这允许我们在组件仍在加载时定义加载状态。

“暂停”(Suspense)的真正威力来自于它与 React 的并发特性的深度集成。当一个组件被暂停,例如因为它仍在等待数据加载,React 并不会闲置不动,直到组件接收到数据。相反,它会暂停渲染被暂停的组件,并将注意力转移到其他任务上。

在此期间,我们可以告诉 React 渲染一个回退的用户界面,以指示该组件仍在加载中。一旦等待的数据可用,React 可以在可中断的方式下无缝地恢复之前被暂停的组件的渲染,就像我们之前看到的转换发生的方式一样。

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

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

“暂停”与 React 服务器组件的可流式传输格式相结合,允许高优先级的更新在准备好后立即发送到客户端,而无需等待较低优先级的渲染任务完成。这使得客户端能够更早地开始处理数据,并通过逐步以非阻塞方式展示内容来提供更流畅的用户体验,随着内容的到达逐渐揭示。

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

数据获取

除了渲染更新外,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 服务器组件时非常有用,因为它们无法访问 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>
  );
}

总结

总而言之,React 18 的最新功能在很多方面提高了性能。

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

最后分享两个我的两个开源项目,它们分别是:

这两个项目都会一直维护的,如果你也喜欢,欢迎 star 🥰🥰🥰

原文链接:https://juejin.cn/post/7262737716851753018 作者:Moment