banner
Koresamuel

Koresamuel

Sometimes ever, sometimes never.
github
twitter
email

React18是如何提升应用性能的

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

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

主线程与长任务#

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

主线程负责一一处理任务

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

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

image

这个 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 组件树之间的差异,并准备必要的更新。

image

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

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

image

同步渲染是一个 “全有或全无” 的操作,可以保证开始渲染的组件会一直完成渲染过程。根据组件的复杂性,渲染阶段可能需要一些时间才能完成。在此期间,主线程被阻塞,意味着用户在尝试与应用程序交互时会遇到无响应的界面,直到 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 以反映新的状态。这个时机可能是在浏览器处于空闲状态时,没有更高优先级的任务(比如用户交互)正在等待的时候。

image

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

image

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

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

image

我们可以通过将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 大小取决于开发者。开发者可以通过以下方式实现:

  • 确保只有交互式组件的最底层节点定义了“use client”指令。这可能需要对一些组件进行解耦。
  • 将组件树作为 props 传递,而不是直接导入。这允许 React 将子组件作为 RSC 进行渲染,而无需将它们添加到客户端 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 的组件的渲染,并将注意力转移到其他任务上。

image

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

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

image

一旦准备就绪,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 的自动缓存行为允许从全局模块中导出一个单一的函数,并在整个应用程序中重复使用它。

image

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 的最新功能在许多方面都提高了性能。

  • 通过 并发 React,渲染过程可以暂停并稍后继续,甚至可以放弃。这意味着即使一个大的渲染任务正在进行中,UI 也可以立即对用户输入做出响应。
  • Transitions API 允许在数据获取或屏幕切换期间实现更平滑的过渡,而不会阻塞用户输入。
  • React Server Component 使开发人员能够构建在服务器和客户端上都能工作的组件,将客户端应用的交互性与传统服务器渲染的性能相结合,而不需要进行补水。
  • 扩展的Suspense功能通过允许应用程序的部分部分在其他部分之前渲染,从而提高了加载性能,这些部分可能需要更长时间来获取数据。

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

Reference#

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.