React18性能优化指南

3,844 阅读15分钟

原文地址:vercel.com/blog/how-re…

  • 本文带你了解TransitionsSuspenseReact Server Components等并发功能如何提高应用程序性能。
  • 熟悉React的实现思路
  • 学习React框架的进化思路

React18引入了并发功能并从根本上改变React应用的渲染方式,我们将会研究这些最新功能的影响以及如何提升应用的性能。

文章的一开始让我们先复习一些基础:长任务和相应地性能测试指标。

1、主线程和长任务

当我们在浏览器运行JS,JS引擎在一个单线程环境里执行代码,这里就关乎到主线程了。伴随着JS代码的执行,主线程同样有义务处理其他任务,包括:管理用户的点击和键盘输入等交互、处理网络请求、定时器、动画更新和管理浏览器的重排和重绘。

image.png

主线程逐个处理任务

当某个任务正在处理,其他任务就必须等待。粒度小的任务可以被浏览器快速执行进而带来丝滑般的用户体验,而长任务则会造成其他正在被执行的任务阻塞。

任何执行时间超过50毫秒的任务都被称之为“长任务”

image.png

50ms的标准是基于设备每16ms(60fps)创建一帧来维持顺滑视觉体验的事实。然而,设备也要处理其他的任务,例如用户的输入和JS脚本的执行等。

50ms的标准允许设备在页面渲染帧和处理其他JS任务中进行资源的合理分配,同时提供额外的大约33.33ms处理其他任务来维持丝滑的视觉体验。你还可以从这篇文章中了解到更多关于50ms的标准的由来。

为了维持最优的性能,减少长任务的数量就尤为关键。为了测量网站的性能,有两个指标是衡量长任务对性能的影响:总的阻塞时长(Total Blocking Time)和交互时长(Interaction to Next Paint)。

TBT(Total Blocking Time)是衡量从FCP(First Contentful Paint)到TTI(Time to Interactive)的时间重要的指标,TBT作为时间的总和,在处理任务超过50ms后会对用户体验有着很明显的影响。

image.png

TBT的值为45ms,因为我们有两个任务的执行时长在TTI之前超过了50ms,分别超过了50ms阈值的30ms和15ms,所以总共的TBT值为这些值的总和:30ms + 15ms = 45ms。

INP(Interaction to Next Paint),作为新的关键Web指标,指的是从用户第一次跟页面交互(例如:点击按钮)到这个交互最终在页面中体现的时间,即下一次渲染。这个指标对于拥有着很多用户交互的页面尤为重要,例如电商网站或者社交媒体平台。它是通过累积用户当前访问期间所有的INP测量值并返回最差分数来测量的。

image.png

INP的值为250ms,因为这是最高的视觉延迟时间

为了理解新React在这些衡量指标上的更新优化,理解传统的React的工作方式变得更加重要。

2、传统的React渲染

React中的视觉更新分为了两个阶段:render阶段和commit阶段。render阶段是一个纯计算的阶段,react元素与已经存在的DOM进行核对,这个阶段包含了创建了新的虚拟DOM树——本质上是内存里一个对真实DOM的一种轻量表达。

在render阶段,React会计算当前DOM与新的React组件树的差异并且提供一些必要的更新。

image.png

commit阶段紧接着Render阶段的后面,在这个阶段,React会运用在render阶段计算出来的结果更新到真实的DOM上。这个期间会包括:创建、更新和删除DOM节点并反映到新的React组件树上。

在传统的同步渲染中,React对组件树里面的所有元素给予了同等的优先级,当组件树渲染的时候,无论是初始渲染还是状态更新,React将会用一种不可中断的任务方式继续渲染,然后会被提交到DOM,进而直观地更新屏幕上的组件。

image.png

同步渲染是“孤注一掷”的操作,保证开始渲染的组件最终都会完成。根据组件的复杂度,render阶段需要花上一段时间来完成。主线程在这个时期会被阻塞,这意味着用户试图与应用交互的时候会体验到卡顿,直到React完成render并且更新到真实的DOM上。

有个经典的DEMO,我们有一个文本输入字段和一个大的城市列表,这些城市将根据文本输入的当前值进行筛选。在同步渲染中,React将继续并在每次击键时重新渲染CitiesList组件。这是一个相当耗时的计算,因为列表由数万个城市组成,所以在键盘点击和看到文本输入中反映这一点之间存在明显的视觉反馈延迟。

// CityList.js
import cities from "cities-list";
import React, { useEffect, useState } from "react";
const citiesList = Object.keys(cities);

const CityList = React.memo(({ searchQuery }) => {
  const [filteredCities, setCities] = useState([]);

  useEffect(() => {
    if (!searchQuery) return;

    setCities(() =>
      citiesList.filter((x) =>
         x.toLowerCase().startsWith(searchQuery.toLowerCase())
      )
    );
   }, [searchQuery]);

  return (
     <ul>
       {filteredCities.map((city) => (
         <li key={city}>
           {city}
        </li>
       ))}
    </ul>
    )
});

export default CityList;

// app.js
import React, { useState } from "react";
import CityList from "./CityList";

export default function SearchCities() {
  const [text, setText] = useState("Am");

   return (    
      <main>      
          <h1>Traditional Rendering</h1>      
          <input type="text" onChange={(e) => setText(e.target.value) }   />      
          <CityList searchQuery={text} />    
      </main>  
     );
};

我们打开性能tab,你会看到用户的每次输入都会产生一个长任务,这是很影响性能。

image.png

右上角红标的被认为是长任务,总的延迟时间为4425.40ms

在这样的场景下,React开发者通常会用第三方库debounce来延迟渲染,但这不是一种内置的解决方案。

React18引进了一种新的并发渲染器,它在后台执行。这个渲染器会暴露一些方式给我们来标记哪些渲染是不紧急的。

image.png

当渲染到低优先级的组件(粉色),React会让出主线程,让主线程来检查其他更重要的任务

在这个案例中,React会每5ms回到主线程来检查是否有更重要的任务去处理,例如用户输入或甚至渲染其他React组件的状态更新,只是因为这个时候这个状态更新比用户体验更重要。通过持续的让回主线程,React有能力让这样的渲染变成非阻塞的并优先考虑更重要的任务。

image.png

相较于每次渲染中的不可中断的任务,并发渲染器在低优先级组件渲染的时候会每5ms让回主线程的控制权。另外,并发渲染器会在后台同时渲染多个版本的组件树并且不会及时提交结果(反映到实际组件上)。

由于同步渲染是一次“孤注一掷”的计算,并发渲染允许React暂停和恢复渲染中的一个或者多个组件树,进而达到最佳的用户体验。

image.png

React因为用户交互暂停了当前的渲染,进而强制优先渲染另一个更新

因为并发特性,React能够基于外部的用户交互事件暂停和恢复组件的渲染,当用户开始与ComponentTwo交互,React暂停了当前的渲染,优先考虑渲染ComponentTwo,然后恢复ComponentOne的渲染。我们会在Suspense这个部分进一步探讨这个话题。

3、Transitions

我们可以使用useTransition这个hook提供的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.png

transition很适合运用在上面的CitiesList的demo中,相较于直接地将用户的输入值传入到searchQuery——最终导致每次输入产生一个同步渲染,我们可以将状态分成两个值,并且将searchQuery的状态包装在startTransition里面。

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

// app.js
import React, { useState, useTransition } from "react";
import CityList from "./CityList";

export default function SearchCities() {
  const [text, setText] = useState("Am");
  const [searchQuery, setSearchQuery] = useState(text);
  const [isPending, startTransition] = useTransition();

   return (    
      <main>      
          <h1><code>startTransition</code></h1>      
          <input  
              type="text" 
              value={text}
              onChange={(e) => {
                 setText(e.target.value)
                 startTransition(() => {
                    setSearchQuery(e.target.value)
                 })
             }}  />      
          <CityList searchQuery={searchQuery} />    
      </main>  
     );
};

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

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

从性能tab来看,与不使用startTransition的实现的性能图相比,在startTransition中包装状态更新显著减少了长任务的数量和总阻塞时间。

image.png

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

4、React Server Components

React Server Components虽然是React18中的一个实验性质的功能,但是已经达到了可以被框架采纳的阶段了。在我们深入研究Next.js之前,了解这一点非常重要。

在传统的React项目中,提供了一些渲染app的方式。我们要么完全依靠客户端来渲染一切(CSR),要么我们可以在服务端将组件树渲染成html并且将这个含有JS bundle的静态html发送给客户端,让组件在客户端渲染(SSR)。

image.png

这两种方式都依赖这样的一个事实:同步react渲染器需要使用JS bundle重构客户端组件树,即使这个组件树已经在服务器上可用。

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

image.png

我们可以通过以下这些方法来帮助我们运用这种新的渲染方式:

  • 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不会激活React Server Components。服务端组件不应该使用任何客户端交互(如访问窗口对象),也不应该使用hooks(如useState或useEffect)。要将组件及其导入添加到发送到客户端的JS bundle中,从而使其具有交互性,可以使用文件顶部的“use client”bundler指令。这告诉bundler将该组件及其导入添加到客户端bundle中,并且告诉React对客户端树进行激活以添加交互性。此类组件称为客户端组件。

image.png

开发者使用客户端组件(Client Components)的时候可以依据以下措施来优化bundle体积:

  • 确保只有交互式组件的叶子节点定义“使用客户端”指令,这可能需要一些组件解耦。
  • 将组件树作为Props传递,而不是直接引用它们。这允许React将子节点作为React服务端组件渲染,而无需将它们添加到客户端bundle中。

5、Suspense

另一个重要的并发特征则是Suspense,尽管这不是一个全新的功能(React16已经引入Suspense,用来与React.lazy结合使用解决代码分包),但是在React18中已经将这个能力扩展到了数据请求的方面。

在使用Suspense的时候,我们可以延迟组件的渲染,直到实际需要渲染的条件出现时,例如:从远端加载数据。与此同时,我们可以在组件加载中渲染一个fallback组件。

通过声明式定义加载状态,我们减少了对任何条件渲染逻辑的处理。通过将SuspenseReact Server Components结合使用,我们可以直接访问服务器端数据源,而无需单独的API,如数据库或文件系统。

async function BlogPosts() {
  const posts = await db.posts.findAll();
  return '...';
}
 
export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <BlogPosts />
    </Suspense>
  )
}

Suspense会让React Server Components运行的更加丝滑,因为这会允许我们在组件加载的时候定义一个加载状态

Suspense最核心的能力来自于与React并发特性的深度结合。当一个组件被暂停(仍然在等待数据加载),React不会白白等到组件接收到数据去接着渲染,相反地,它会暂停渲染组件并且会切换到其他它在关注的任务。

image.png

在此期间,我们可以让React去渲染一个fallback UI来表明当前的组件正在加载中。一旦等待的数据变得可用,React会顺滑地用一种可中断的方式恢复之前被暂停的组件渲染,就像我们之前看到的transitions一样。

React也会依据用户的交互来重新定义组件的优先级,例如:当一个用户在页面有Suspense组件渲染的时候进行页面交互,React会暂定当前的渲染并重新调整用户实际交互的组件的优先级。

image.png

一旦组件准备完成,React会将它提交给DOM并恢复之前的渲染。这确保了用户交互的优先级,并且UI随着用户的输入始终可响应并且保持最新。

SuspenseReact Server Component的流式形态结合会让高优先级的更新在完成以后更早地发送到客户端,而不需要等到低优先级的渲染任务完成。通过逐步地将页面相关内容以一种非阻塞的方式送达客户端,这就会使得客户端更早地处理数据并提供了一种更流畅的用户体验。

这种可中断的渲染机制,即集成了可以处理异步操作的Suspense能力,提供了一种更加顺滑更加用户中心的体验,尤其在一些复杂的且含有重要数据请求的应用中。

6、Data Fetching

除了渲染更新,React18也新增了一个新的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。缓存和Fetch的自动缓存行为允许从全局模块导出单个函数,并在整个应用程序中重用它。

image.png

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

7、总结

React18的最新功能通过很多方式来改善性能,总结如下:

  • React的并发能力,React的渲染过程会被暂停并在之后进行恢复或甚至丢弃。这也就意味着UI可以在用户输入的时候得到及时响应,就算当前有一个大型的渲染任务在执行中。
  • Transitions API允许在数据获取或屏幕更改期间进行更平滑的转换,而不会阻止用户输入。
  • React Server Components允许开发人员构建在服务器和客户端上都能工作的组件,将客户端应用程序的交互性与传统服务器渲染的性能相结合,而不需要激活的成本。
  • 升级后的Suspense功能通过允许应用程序的部分组件在其他部分组件之前渲染来提高加载性能,这些其他组件可能需要更长的时间才能获取数据。

使用Next.js's App Router的开发人员可以开始利用现在框架可用的功能,如在这篇文章中提到的缓存和服务器组件。

PS:欢迎交流,逻辑不通顺的地方欢迎指正