React18是如何提升应用性能的
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毫秒的任务执行所花费的时间的总和,这可能会对用户体验产生重大影响。
INP(Interaction to Next Paint)#
可交互到下一次绘制(INP)是一个新的核心网络性能指标,它衡量了用户首次与页面进行交互(例如点击按钮)到该交互在屏幕上可见的时间;即下一次绘制。这个指标对于具有许多用户交互的页面,比如电子商务网站或社交媒体平台,尤其重要。它通过累积用户在当前访问期间的所有INP测量值,并返回最差得分来进行衡量。
为了理解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选项卡时,你会发现每次按键都会出现长时间的任务,这是不太理想的情况。
在这种情况下,React开发人员通常会使用第三方库(例如Debounce
)推迟渲染,但是没有内置的解决方案。
React 18引入了一种新的并发渲染器,它在幕后运行。这个渲染器提供了一些方法,让我们可以将某些渲染标记为非紧急的。
在这种情况下,React将每5毫秒让出主线程,以查看是否有更重要的任务需要处理,比如用户输入,甚至是渲染另一个React组件状态更新,这些在当前情况下对用户体验更重要。通过不断地让出主线程,React能够使这些渲染变得非阻塞,并优先处理更重要的任务。
此外,这个并发渲染器能够在后台“并发”地渲染多个版本的组件树,而不立即提交结果。
而同步渲染是一个全有或全无的计算过程,这个并发渲染器允许React暂停和恢复一个或多个组件树的渲染,以实现最优化的用户体验。
通过使用并发特性,React可以根据用户交互等外部事件暂停和恢复组件的渲染。当用户开始与ComponentTwo
进行交互时,React会暂停当前的渲染,优先渲染ComponentTwo
,然后再恢复渲染ComponentOne
。
Transitions#
我们可以使用由useTransition
钩子提供的startTransition
函数,将更新标记为非紧急状态。这是一个强大的新功能,允许我们将某些状态更新标记为“transitions”,表示它们可能导致视觉变化,如果它们以同步方式渲染可能会干扰用户体验。
通过将状态更新包装在startTransition
中,我们可以告诉React,我们可以推迟或中断渲染,以优先处理更重要的任务,保持当前用户界面的互动性。
当transition开始时,并发渲染器会在后台准备新的树形结构。一旦渲染完成,它会将结果保留在内存中,直到React调度程序能够高效地更新DOM以反映新的状态。这个时机可能是在浏览器处于空闲状态时,没有更高优先级的任务(比如用户交互)正在等待的时候。
在CitiesList
演示中使用transition是非常理想的。与其在每次按键时直接更新传递给searchQuery
参数的值(从而在每次按键时触发同步渲染调用),我们可以将状态拆分为两个值,并将searchQuery
的状态更新包装在startTransition
中。
这会告诉React,状态更新可能会导致对用户有干扰的视觉变化,因此React应该尝试在后台准备新状态的同时保持当前的交互式UI,而不立即提交更新。
现在,当我们在输入字段中键入时,用户输入保持流畅,没有按键之间的视觉延迟。这是因为text
状态仍然是同步更新的,输入字段将其用作value
。
在后台,React在每次按键时都开始渲染新的树形结构。但与其成为一个全有或全无的同步任务不同,React开始在内存中准备新版本的组件树,同时当前的UI(显示“旧”状态)保持对进一步用户输入的响应。
在Performance选项卡中,将状态更新包装在startTransition
中显著减少了长时间任务的数量和总阻塞时间,与没有使用转换的性能图表相比。
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/server
的renderToPipeableStream
方法与react-dom/client
的createRoot
方法相结合,来使用这种新的渲染模式。
点击此处查看完整的代码和演示。在下一部分中,我们将介绍一个更详尽的示例。
默认情况下,React不会对RSC
进行补水(hydration)。这些组件不应该使用任何客户端交互,比如访问window
对象或使用像useState
或useEffect
这样的钩子。
要将一个组件及其导入添加到JavaScript Bundle中,从而使其具有交互性,可以在文件的顶部使用“use client”指令。这会告诉Bundler
将这个 组件及其导入 添加到客户端Bundle中,并告诉React在客户端进行补水,以添加交互性。这种组件被称为Client Components
(客户端组件)。
在使用Client Components
时,优化Bundle大小取决于开发者。开发者可以通过以下方式实现:
- 确保只有交互式组件的最底层节点定义了
“use client”
指令。这可能需要对一些组件进行解耦。 - 将组件树作为props传递,而不是直接导入。这允许React将子组件作为RSC进行渲染,而无需将它们添加到客户端Bundle中。
Suspense#
另一个重要的新的并发特性是Suspense
。虽然它在React 16中已经发布,用于React.lazy
的代码拆分,但是React 18引入的新功能将Suspense
扩展到了数据获取。
使用Suspense
,我们可以推迟组件的渲染,直到满足某些条件,比如从远程源加载数据。与此同时,我们可以渲染一个回退组件,表示这个组件仍在加载中。
通过声明性地定义加载状态,我们减少了对条件渲染逻辑的需求。使用Suspense
结合RSC
,我们可以直接访问服务器端的数据源,而不需要单独的API端点,比如数据库或文件系统。
Suspense
的真正力量来自它与React的并发特性的深度集成。例如,当一个组件suspended,仍在等待数据加载时,React不会等着组件收到数据闲置不动。相反,它会暂停suspended的组件的渲染,并将注意力转移到其他任务上。
在这段时间里,我们可以告诉React渲染一个回退UI,以指示这个组件仍在加载中。一旦等待的数据可用,React可以通过中断的方式无缝地恢复先前被暂停的组件的渲染,就像我们之前在transitions中看到的一样。
React还可以根据用户交互重新安排组件的优先级。例如,当用户与一个当前没有在渲染的suspended组件进行交互时,React会暂停正在进行的渲染,并优先考虑用户正在交互的组件。
一旦准备就绪,React将其提交到DOM,并恢复之前的渲染。这确保了用户交互的优先级,并且UI保持响应性,与用户输入保持最新。
Suspense
与RSC
的可流式格式的结合,允许高优先级的更新在就绪后立即发送到客户端,无需等待低优先级的渲染任务完成。这使得客户端可以更早地开始处理数据,并逐渐以非阻塞的方式展示内容,为用户提供更流畅的体验。
这种可中断的渲染机制与Suspense
处理异步操作的能力相结合,为复杂应用程序,尤其是具有大量数据获取需求的应用程序,提供了更流畅、更以用户为中心的体验。
数据获取#
除了渲染更新,React 18还引入了一种新的API来高效地获取数据并进行记忆化处理。
React 18现在具有一个缓存函数,它会记住包装函数调用的结果。如果在同一次渲染过程中使用 相同的参数 再次调用同一个函数,它将使用记忆化的值,而无需再次执行函数。
在fetch调用中,React 18现在默认包含了类似的缓存机制,无需使用cache。这有助于减少单个渲染过程中的网络请求次数,从而提高应用程序性能并降低API成本。
当使用React Server组件时,这些功能非常有用,因为它们无法访问Context API。cache和fetch的自动缓存行为允许从全局模块中导出一个单一的函数,并在整个应用程序中重复使用它。
Conclusion#
总之,React 18的最新功能在许多方面都提高了性能。
- 通过 并发React,渲染过程可以暂停并稍后继续,甚至可以放弃。这意味着即使一个大的渲染任务正在进行中,UI也可以立即对用户输入做出响应。
- Transitions API 允许在数据获取或屏幕切换期间实现更平滑的过渡,而不会阻塞用户输入。
- React Server Component 使开发人员能够构建在服务器和客户端上都能工作的组件,将客户端应用的交互性与传统服务器渲染的性能相结合,而不需要进行补水。
- 扩展的
Suspense
功能通过允许应用程序的部分部分在其他部分之前渲染,从而提高了加载性能,这些部分可能需要更长时间来获取数据。
使用Next.js的App Router的开发人员现在可以开始利用在文章中提到的缓存和Server Components等框架可用的功能