React 18 引入的并发功能,从根本上改变了 React 应用程序的渲染方式。本文将带大家一同探讨这些最新功能的具体作用,特别是如何提高应用程序性能。
首先,我们先从长任务基础知识说起,聊聊其中的性能测量思路。
主线程与长任务
当我们在浏览器中运行 JavaScript 时,JS 引擎会在单线程环境下执行代码内容,而该环境通常称为主线程。除了执行 JS 代码之外,主线程还负责处理其他任务,包括管理用户交互(如鼠标单击和键盘输入)、处理网络事件、计时器、更新动画并管理浏览器的重排和重绘等。
主线程负责逐一处理各项任务
在处理一项任务时,其余任务均处于等待状态。虽然浏览器在执行小规模任务时能提供丝滑无缝的用户体验,但面对更长的任务时则可能出现问题。由于任务耗时较长,其他任务可能一直被悬置和等待。一般来说,任何运行时间超过 50 毫秒的任务均被定义为“长任务”。
这里的 50 毫秒标准基于以下事实:设备必须每 16 毫秒生成一个新帧(相当于 60 fps)才能保持流畅的视觉体验。然而,设备在此期间还需要执行其他任务,例如响应用户输入和执行 JavaScript 代码。
所以 50 毫秒基准测试能保证设备为帧渲染和其他任务保留必要的资源,始终提供额外的约 33.33 毫秒时间执行其他任务。感兴趣的朋友可以参阅这篇博文:https://web.dev/rail/#response-process-events-in-under-50ms
其中借由 RAIL 模型探讨了关于 50 毫秒基准测试的更多信息。
为了保持最佳性能,尽量控制长任务的数量显然非常重要。在测量网站性能时,我们一般通过两项指标来了解长耗时任务对应用程序性能的影响:总阻塞时间,以及下次绘制的交互。
总阻塞时间(TBT)是用于衡量首次内容绘制(FCP)和交互时间(TTI)之间间隔时长的重要指标。总阻塞时间代表着所有执行时间超过 50 毫秒的任务的总和,它们往往是对用户体验影响最大的因素。
上图中的总阻塞时间为 45 毫秒,因为我们有两个任务在首次交互时间(TTI)之前花费超过 50 毫秒,而超出这项阈值的部分分别是 30 毫秒和 15 毫秒。所以总阻塞时间就是这些值的累加:30 毫秒 +15 毫秒 =45 毫秒。
下次绘制的交互(INP)则是 Core Web Vitals 提出的新指标,测量的是用户在第一次与页面交互(例如单击某个按钮)到交互结果在屏幕上显示出来之间的间隔,即下次绘制。该指标对于包含大量用户交互元素的页面特别重要,例如电子商务网站或者社交媒体平台。它的衡量方式,是将用户当前访问期间所有 INP 的测量值累加起来,再计算出最差得分。
上图中的下次绘制的交互时间为 250 毫秒,这也是测量期间得到的最高视觉延迟。
要了解 React 18 的更新如何针对这些指标进行优化、从而改善用户体验,我们首先需要明确 React 以往版本的工作原理。
以往 React 中的渲染机制
React 中的视觉更新主要分为两个阶段:渲染阶段与提交阶段。这里的渲染阶段属于纯计算阶段,期间 React 元素与现有 DOM 进行协调(即比较)。此阶段需要创建新的 React 元素树,也被称为“虚拟 DOM”,它本质上就是 DOM 在轻量级内存中的表示形式。
在渲染阶段,React 会计算当前 DOM 和新 React 组件树之间的差异,并准备好必要的更新。
渲染阶段之后则是提交阶段。在此阶段,React 会将渲染阶段计算出的更新应用于实际 DOM。具体过程包括创建、更新和删除 DOM 节点,从而映射新的 React 组件树。
在传统的同步渲染中,React 会给组件树中的所有元素赋予相同的优先级。在对组件树进行渲染时,无论是在初始渲染还是在状态更新时,React 都会在单一不间断任务中持续渲染该树,之后将结果提交给 DOM 以直观更新屏幕上显示的组件。
同步渲染属于是“全有或全无”的操作,它会保证已经开始渲染的组件必定能够完成。而根据组件的具体复杂性,渲染阶段可能要持续一段时间才能完成。在此期间,主线程将始终被阻塞,因此用户与 UI 间的交互将失去响应,直到 React 完成渲染并将结果提交至 DOM。
以下演示就展现了这样的情况。我们设置了一个文本输入字段,外加一份巨大的城市列表,这里要根据文本输入的当前值进行过滤。在同步渲染中,React 会在每次键盘输入时重新渲染 CityList 组件。因为列表当中包含数以万计的城市,所以计算负担相当沉重,导致键盘输入和显示文本响应之间出现相当明显的视觉反馈延迟。
App.js:
CityList.js:
index.js:
style.css:
如果大家使用的是 MacBook 这样高端计算设备,可能需要限制 CPU 4x 来模拟低端设备性能。大家可以在 Devtools > Performance > ⚙️ > CPU 中找到这项设置。
在查看性能选项卡时,可以发现每次键盘输入都会产生长任务,这明显有违优化原则。
标有红角的任务就是“长任务”。请注意,这里的总阻塞时间为 4425.40 毫秒。
在这种情况下,React 开发人员经常会使用 debounce 等第三方库来做延迟渲染,但 React 本身并没有内置的解决方案。
React 18 引入了幕后运行的新并发渲染器。该渲染器为我们提供了一些将某些渲染任务标记为非紧急的办法。
当渲染低优先级组件(粉色)时,React 会返回主线程以检查是否存在更重要的任务。
在这种情况下,React 每 5 毫秒就会返回一次主线程,看看有没有更重要的任务等待处理。这可能是用户输入,也可能是渲染另一个对当前用户体验更重要的 React 组件状态更新。通过不断返回主线程,React 就能让此类渲染获得非阻塞保障、凭借更高的重要性得到优先处理。
并发渲染器并不再为每次渲染执行一项不可中断的任务,而是在低优先级组件的(重新)渲染期间以 5 毫秒为间隔,定期为主线程提供控制权。
此外,并发渲染器还能在后台“同时”渲染组件树的多个版本,且无需立即提交结果。
相较于全有或全无的同步渲染计算,并发渲染器允许 React 暂停和恢复对一个或多个组件树的渲染,从而实现最佳用户体验。
React 会根据用户交互暂时叫停当前渲染,强制要求主线程优先渲染另一更新。
使用并发功能,React 能够根据外部事件(如用户交互)暂停和恢复组件渲染。当用户开始与 COmponentTwo 交互时,React 会暂停当前渲染、优先渲染 ComponentTwo,之后再恢复渲染 ComponentOne。我们将在暂停部分具体讨论这部分内容。
过渡(Transitions)
我们可以使用 userTransition 钩子提供的 startTransition 函数,将某些更新标记为非紧急。这是一项强大的新功能,允许我们将某些状态更新标记为“transitions”,表明它们与视觉变化相关,如果继续以同步渲染方式处理则可能会破坏用户体验。
通过在 startTransition 中打包状态更新,我们可以要求 React 推迟或中断当前渲染,腾出手来优先处理更重要的任务,从而保证当前用户界面的可交互性。
在过渡开始时,并发渲染器会在后台准备新树。一旦完成渲染,它会将结果保留在内存内,直到 React 调度程序可以更有效地更新 DOM 以反映新状态。具体时间点可能是在浏览器空闲,而且没有待处理的高优先级任务(例如用户交互)的情况下。
对于演示中的 CityList 用例来说,过渡机制的表现堪称完美。我们可以将状态拆分成两个值,并将 searchQuery 的状态更新打包在 startTransition 当中,而不再在每次键盘输入时直接将更新传递给 searchQuery 参数(这会导致每次键盘输入都触发同步渲染)。
这种方式相当于告知 React,某些状态更新可能会导致视觉变化,进而对用户造成干扰。因此 React 应该尽量保持当前 UI 的可交互性,同时在后台准备新状态、但暂时不立即提交。
index.js:
App.js:
CityList.js:
style.css:
现在,当我们在输入字段处键入内容时,用户的输入感受始终流畅,每次键入间没有任何视觉延迟。这是因为 text 状态始终保持同步更新,并供输入字段作为 value 使用。
在后台,React 开始在每次键入时渲染新树。但这时其执行的已经不再是全有或者全无的同步任务,React 开始在内存中准备组件树的新版本,而当前 UI(显示「旧」状态)则继续响应用户的每一步后续输入。
下面来看性能选项卡。与不使用 transition 的实现相比,将状态更新打包进 startTransition 能显著缩降低长任务数量和总阻塞时间。
性能选项卡显示,长任务数量和总阻塞时间均有显著降低。
过渡也是 React 渲染模型中的根本性转变之一,令 React 能够同时渲染多个版本的 UI,并管理不同任务间的优先级差异。这将带来更流畅、响应更灵敏的用户体验,这一点在处理高频更新或 CPU 密集型渲染任务时效果尤其突出。
React Server Components
React Server Components 是 React 18 中的一项实验性功能,但已经为框架应用做好了准备。有了这个前提,下面我们开始深入探讨 Next.js。
在传统上,React 为我们的应用程序提供几种主要的渲染方式。我们可以完全在客户端侧渲染所有内容(即客户端渲染),也可以在服务器上将组件树渲染为 HTML,再使用 JavaScript 捆绑包将该静态 HTML 发送至客户端,从而在客户端上填充组件(即服务器侧渲染)。
这两种方法都基于这样一个事实:同步的 React 渲染器需要使用附带的 JavaScript 捆绑包在客户端重建组件树,即使该组件树已经在服务器上可用也不例外。
React Server Components 允许 React 将实际的序列化组件树发送至客户端。客户端 React 渲染器能够识别这种格式,并使用它高效重建 React 组件树,期间无需发送 HTML 文件或者 JavaScript 捆绑包。
我们可以将 react-server-dom-webpack/server 的 renderToPipeableStream 方法与 react-dom/client 的 createRoot 方法结合起来,使用这种新的渲染模式。
⚠️ 这里是后文中 CodeSandbox 演示的极度简化(请注意!)示例。
点击此处查看完整的 CodeSandbox 演示(https://codesandbox.io/p/sandbox/cocky-minsky-m7sgfx)。在下一节中,我们将介绍更为详尽的示例。
在默认情况下,React 不会对 React 服务器组件进行水合。这些组件不应使用任何客户端交互性(例如 window 对象),也不会使用 useState 或 useEffect 等钩子。
要将组件及其导入添加至发送到客户端的 JavaScript 捆绑包中以使其具有交互性,我们可以使用文件开头的“use client”捆绑器指令。这是告知捆绑器将此组件及其导入添加到客户端捆绑包内,并告知 React 对树客户端进行水合以添加交互性。此类组件,就被称为 Client Components。
注意:框架的具体实现可能有所区别。例如,Next.js 会在服务器上将客户端组件预渲染为 HTML,类似于传统 SSR 方法。但在默认情况下,Client Components 的渲染更类似于 CSR 方法。
在使用 Client Components 时,开发人员需要对捆绑包大小进行优化。具体优化方式包括以下几种:
确保只有指令组件的最末叶节点处定义“use client”指令。这可能需要进行某些组件解耦。
将组件树作为 props 传递,而不是直接导入。这样 React 可以将 children 渲染为 React Server Components,因此不必将其添加至客户端捆绑包内。
Suspense
React 18 中另一个重要新并发功能是 Suspense。虽然 Suspense 并非全新(最初发布于 React 16),最初用于通过 React.lazy 进行代码分割,但 React 18 将 Suspense 扩展到了数据获取。
使用 Suspense,我们可以延迟组件渲染,直到满足某些条件(例如从远程源加载数据)再恢复。同时,我们可以渲染一个后备组件,指示该组件仍然加载。
通过对加载状态做声明性定义,我们减少了对渲染逻辑条件的需求。将 Suspense 与 React Server Components 结合使用,我们能够直接访问服务器侧数据源(例如数据库或文件系统),而不需要借助单独的 API 端点。
使用 React Server Components 与 Suspense 无缝协作,我们能够在组件仍在加载时定义加载状态。
Suspense 的真正力量,来自它与 React 并发功能的深度集成。当组件被挂起时,例如仍在等待数据加载,React 不会在组件真正收到数据前保持闲置。相反,它会暂停组件的渲染,并将其焦点转移至其他任务。
在此期间,我们可以告知 React 渲染一个后备 UI,以指示该组件仍在加载。一旦等待的数据可用,React 将以可中断的方式无缝恢复对先前挂起组件的渲染,效果与我们之前讨论的过渡(transition)一样。
React 还能根据用户交互重新调整各组件的优先级。例如,当用户与当前未渲染的挂起组件进行交互时,React 会挂起当前正进行的渲染,并优先处理用户正在与之交互的组件。
在准备就绪之后,React 会将其提交至 DOM,并恢复之前的渲染。这确保了用户交互的优先级,且 UI 将保持响应并根据用户输入保持随时最新状态。
Suspense 与 React Server Component 的可流式传输格式相结合,将使高优先级更新在准备好后立即发送至客户端,而无需等待低优先级渲染任务的完成。这样客户端能够更快开始处理数据,并保证将非阻塞方式到达的内容逐渐显示出来,从而提供更流畅的用户体验。
这种可中断的渲染机制与 Suspense 处理异步操作的能力相结合,带来更流畅、更加以用户为中心的体验。这在具有大量数据获取需求的复杂应用程序中尤其重要。
数据获取
除了渲染更新之外,React 18 还引入一个新的 API,用以有效获取数据并在内存中存储结果。
React 18 现在提供一个缓存函数,能够存储已打包的函数调用的结果。如果您在同一渲染通道中使用具有相同参数的同一函数,则 React 18 会使用内存内的值,无需再次执行该函数。
在 fetch 调用中,React 18 现在默认包含类似的缓存机制,而无需使用 cache。这有助于减少单个渲染通道中的网络请求数量,从而提高应用程序性能并降低 API 成本。
这些功能能够与 React Server Components 配合发挥重要作用。因为 React Server Components 无法访问 Context API,而缓存与获取的自动缓存行为允许从全局模块导出单个函数,并在整个应用程序范围内复用。
总结
总的来说,React 18 的各项新功能在诸多方面提高了应用程序的性能水平。
使用 Concurrent React 并发功能,渲染过程可以暂停并稍后恢复,甚至将其弃用。这样即使是在执行大型渲染任务期间,UI 也能立即响应用户的输出。
Transitions API 允许在数据获取或屏幕内容变更期间,实现更平滑的过渡而不会阻止用户输入。
React Server Components 允许开发人员构建起可在服务器和客户端上运行的组件,将客户端应用程序的交互性与传统服务器渲染的高性能相结合,且无需借助水合机制。
扩展后的 Suspense 功能允许先呈现应用程序中的某些部分,期间逐步显示其他可能需要更长时间获取的数据,从而提高加载性能。
原文链接:
https://vercel.com/blog/how-react-18-improves-application-performance
评论